diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5576e43 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + test: + name: ${{ matrix.os }} / py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install pytest + run: | + python -m pip install --upgrade pip + python -m pip install pytest + + - name: Syntax check + run: python -m compileall -q lib bin ccb + + - name: Run tests + run: pytest test/ -v + diff --git a/.gitignore b/.gitignore index 0fc67c8..d310f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ __pycache__/ *.pyc +.venv/ +.pytest_cache/ +docs/ +tests/ .codex-session .gemini-session .claude-session +.opencode-session +opencode.json .claude/ *.mp4 +tmp/ +compare_with_mcp/ +CLAUDE.md +AGENTS.md +openspec diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f8a71c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +## Auto-Delegation to OpenCode + +When executor=codex+opencode: +1. Break down the task into steps +2. Use oask to delegate to OpenCode +3. Review results and iterate +4. Return final result diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6f8b745..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -- 该文件夹是 claude_code_bridge (ccb) 开发文件夹,要注意兼容性,同时修改代码注意同时修改install,安装使用install安装,完成后要git增加版本并推送 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f09f9ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,48 @@ +# AGPL-3.0 - GNU Affero General Public License v3.0 + +## English + +This software is licensed under the AGPL-3.0 license, which means: + +- **Attribution Required**: You must give appropriate credit, provide a link to the license, and indicate if changes were made. When using Claude Code Bridge (CCB), please credit the original project. + +- **Open Source Required**: If you modify this software and distribute it or run it as a service, you must release your source code under AGPL-3.0. + +- **Network Use (Copyleft)**: If you run this software as a network service (e.g., SaaS), users interacting with it over the network must be able to receive the source code. + +- **No Closed-Source Use**: You cannot use this software in proprietary/closed-source projects unless you open-source the entire project under AGPL-3.0. + +**In short**: You can use Claude Code Bridge for free, but if you build upon it, your code must also be open-sourced under AGPL-3.0 with attribution to this project. Closed-source commercial use requires a separate license. + +For commercial licensing inquiries (closed-source use), please contact the maintainer. + +--- + +## 中文 + +本软件采用 AGPL-3.0 许可协议,这意味着: + +- **署名要求**:您必须注明出处,提供许可协议链接,并说明是否进行了修改。使用 Claude Code Bridge (CCB) 时,请注明项目来源。 + +- **开源要求**:如果您修改此软件并将其分发或作为服务运行,则必须根据 AGPL-3.0 发布您的源代码。 + +- **网络使用(Copyleft)**:如果您将此软件作为网络服务(例如 SaaS)运行,则通过网络与其交互的用户必须能够接收源代码。 + +- **禁止闭源使用**:您不能在专有/闭源项目中使用此软件,除非您将整个项目根据 AGPL-3.0 开源。 + +**简单来说**:您可以免费使用 Claude Code Bridge,但如果您基于它进行开发,您的代码也必须根据 AGPL-3.0 开源,并注明本项目。闭源商业用途需要单独的许可证。 + +对于商业许可咨询(闭源使用),请联系维护者。 + +--- + +## Full License Text + +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +For the complete license text, see: https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/README.md b/README.md index 784b4ec..f39ca03 100644 --- a/README.md +++ b/README.md @@ -1,379 +1,353 @@
-# Claude Code Bridge (ccb) v2.1 +# Claude Code Bridge (ccb) v3.0.0 -**🌍 Cross-Platform Multi-AI Collaboration: Claude + Codex + Gemini** +**Silky Smooth Claude & Codex & Gemini Collaboration via Split-Pane Terminal** -**Windows | macOS | Linux — One Tool, All Platforms** +**Build a real AI expert team. Give Claude Code / Codex / Gemini / OpenCode partners that never forget.** -[![Version](https://img.shields.io/badge/version-2.1-orange.svg)]() +

+ 交互皆可见 + 模型皆可控 +

+

+ Every Interaction Visible + Every Model Controllable +

+ +[![Version](https://img.shields.io/badge/version-3.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](#english) | [中文](#中文) - -Dual-pane demo (animated) +**English** | [中文](README_zh.md) -

- Full demo video (GitHub Release) -

+Split-pane collaboration demo
--- -## 🎉 What's New in v2.1 - -> **🪟 Full Windows Support via [WezTerm](https://wezfurlong.org/wezterm/)** -> WezTerm is now the recommended terminal for all platforms. It's a powerful, cross-platform terminal with native split-pane support. Linux/macOS users: give it a try! tmux remains supported. +**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 solution. -- **⚡ Faster Response** — Optimized send/receive latency, significantly faster than MCP -- **🐛 macOS Fixes** — Fixed session resume and various login issues -- **🔄 Easy Updates** — Run `ccb update` instead of re-cloning +## ⚡ Why ccb? -> Found a bug? Run `claude` in the project directory to debug, then share your `git diff` with the maintainer! +| 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. | --- -# English +

🚀 What's New in v3.0

-## Why This Project? +> **The Ultimate Bridge for Cross-AI Collaboration** -Traditional MCP calls treat Codex as a **stateless executor**—Claude must feed full context every time. +v3.0 brings a revolutionary architecture change with **Smart Daemons**, enabling parallel execution, cross-agent coordination, and enterprise-grade stability. -**ccb (Claude Code Bridge)** establishes a **persistent, lightweight channel** for sending/receiving small messages while each AI maintains its own context. +
-### Division of Labor +![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) -| Role | Responsibilities | -|------|------------------| -| **Claude Code** | Requirements analysis, architecture planning, code refactoring | -| **Codex** | Algorithm implementation, bug hunting, code review | -| **Gemini** | Research, alternative perspectives, verification | -| **ccb** | Session management, context isolation, communication bridge | +
-### Official MCP vs Persistent Dual-Pane +

✨ Key Features

-| Aspect | MCP (Official) | Persistent Dual-Pane | -|--------|----------------|----------------------| -| Codex State | Stateless | Persistent session | -| Context | Passed from Claude | Self-maintained | -| Token Cost | 5k-20k/call | 50-200/call (much faster) | -| Work Mode | Master-slave | Parallel | -| Recovery | Not possible | Supported (`-r`) | -| Multi-AI | Single target | Multiple backends | +- **🔄 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. -> **Prefer MCP?** Check out [CodexMCP](https://github.com/GuDaStudio/codexmcp) — a more powerful MCP implementation with session context and multi-turn support. +

🧩 Feature Support Matrix

+ +| Feature | `caskd` (Codex) | `gaskd` (Gemini) | `oaskd` (OpenCode) | +| :--- | :---: | :---: | :---: | +| **Parallel Queue** | ✅ | ✅ | ✅ | +| **Interruption Awareness** | ✅ | ✅ | - | +| **Response Isolation** | ✅ | ✅ | ✅ |
-Token Savings Explained +📊 View Real-world Stress Test Results -``` -MCP approach: - Claude → [full code + history + instructions] → Codex - Cost: 5,000-20,000 tokens/call - -Dual-pane approach (only sends/receives small messages): - Claude → "optimize utils.py" → Codex - Cost: 50-200 tokens/call - (Codex reads the file itself) -``` +
-
+**Scenario 1: Claude & Codex Concurrent Access to OpenCode** +*Both agents firing requests simultaneously, perfectly coordinated by the daemon.* -## Install +| Source | Task | Result | Status | +| :--- | :--- | :--- | :---: | +| 🤖 Claude | `CLAUDE-A` | **CLAUDE-A** | 🟢 | +| 🤖 Claude | `CLAUDE-B` | **CLAUDE-B** | 🟢 | +| 💻 Codex | `CODEX-A` | **CODEX-A** | 🟢 | +| 💻 Codex | `CODEX-B` | **CODEX-B** | 🟢 | -```bash -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -./install.sh install -``` +**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` | -### Windows + -- **WSL2 (recommended):** run the same commands inside WSL. -- **Native Windows (PowerShell/CMD):** use the wrappers: - - `install.cmd install` - - or `powershell -ExecutionPolicy Bypass -File .\\install.ps1 install` (you will be prompted for `BackendEnv`) -- **WezTerm-only (no tmux):** run `ccb` inside WezTerm, or set `CCB_TERMINAL=wezterm` (and if needed `CODEX_WEZTERM_BIN` to `wezterm.exe`). +--- -### BackendEnv (Important for Windows/WSL) +

🧠 Introducing CCA (Claude Code Autoflow)

-ccb and codex/gemini must run in the same environment. Otherwise you may see: -- `exit code 127` (command not found) -- `cpend`/`gpend` cannot find replies (different session directories) +Unlock the full potential of `ccb` with **CCA** — an advanced workflow automation system built on top of this bridge. -Install-time selection is persisted to `ccb_config.json` (or override with `CCB_BACKEND_ENV`): -- `BackendEnv=wsl`: codex/gemini runs in WSL (recommended if you type `codex` inside a WSL shell). -- `BackendEnv=windows`: codex/gemini runs as native Windows CLI. +* **Workflow Automation**: Intelligent task assignment and automated state management. +* **Seamless Integration**: Native support for the v3.0 daemon architecture. -## Start +[👉 View Project on GitHub](https://github.com/bfly123/claude_code_autoflow) +**Install via CCB:** ```bash -ccb up codex # Start with Codex -ccb up gemini # Start with Gemini -ccb up codex gemini # Start both -ccb up codex -r # Resume previous session -ccb up codex -a # Full permissions mode +ccb update cca ``` -### Session Management +--- + +## 🚀 Quick Start + +**Step 1:** Install [WezTerm](https://wezfurlong.org/wezterm/) (native `.exe` for Windows) + +**Step 2:** Choose installer based on your environment: + +
+Linux ```bash -ccb status # Check backend status -ccb kill codex # Terminate session -ccb restore codex # Attach to running session -ccb update # Update to latest version +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install ``` -> `-a` enables `--dangerously-skip-permissions` for Claude and `--full-auto` for Codex. -> `-r` resumes based on local dotfiles in the current directory (`.claude-session`, `.codex-session`, `.gemini-session`); delete them to reset. +
-## Usage Examples +
+macOS -### Practical Workflows -- "Have Codex review my code changes" -- "Ask Gemini for alternative approaches" -- "Codex plans the refactoring, supervises while I implement" -- "Codex writes backend API, I handle frontend" +```bash +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install +``` -### Fun & Creative +> **Note:** If commands not found after install, see [macOS Troubleshooting](#-macos-installation-guide). -> **🎴 Featured: 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) -> ``` +
-- "Play Gomoku with Codex" -- "Debate: tabs vs spaces" -- "Codex writes a function, Claude finds the bugs" +
+WSL (Windows Subsystem for Linux) -### Advanced -- "Codex designs architecture, Claude implements modules" -- "Parallel code review from different angles" -- "Codex implements, Gemini reviews, Claude coordinates" +> Use this if your Claude/Codex/Gemini runs in WSL. -## Commands (For Developers) +> **⚠️ WARNING:** Do NOT install or run ccb as root/administrator. Switch to a normal user first (`su - username` or create one with `adduser`). -> Most users don't need these—Claude auto-detects collaboration intent. +```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 +``` -**Codex:** +
-| Command | Description | -|---------|-------------| -| `cask-w ` | Sync: wait for reply | -| `cask ` | Async: fire-and-forget | -| `cpend` | Show latest reply | -| `cping` | Connectivity check | +
+Windows Native -**Gemini:** +> Use this if your Claude/Codex/Gemini runs natively on Windows. -| Command | Description | -|---------|-------------| -| `gask-w ` | Sync: wait for reply | -| `gask ` | Async: fire-and-forget | -| `gpend` | Show latest reply | -| `gping` | Connectivity check | +```powershell +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +powershell -ExecutionPolicy Bypass -File .\install.ps1 install +``` -## Requirements +
-- Python 3.10+ -- tmux or WezTerm (at least one; WezTerm recommended) +### Run +```bash +ccb up codex # Start Codex +ccb up gemini # Start Gemini +ccb up opencode # Start OpenCode +ccb up codex gemini # Start both +ccb up codex gemini opencode # Start all three +ccb-layout # Start 2x2 layout (Codex+Gemini+OpenCode) +``` -## Uninstall +### Flags +| Flag | Description | Example | +| :--- | :--- | :--- | +| `-r` | Resume previous session context | `ccb up codex -r` | +| `-a` | Auto-mode, skip permission prompts | `ccb up codex -a` | +| `-h` | Show help information | `ccb -h` | +| `-v` | Show version and check for updates | `ccb -v` | +### Update ```bash -./install.sh uninstall +ccb update # Update ccb to the latest version ``` --- -# 中文 +## 🪟 Windows Installation Guide (WSL vs Native) -## 🎉 v2.1 新特性 +> **Key Point:** `ccb/cask-w/cping` must run in the **same environment** as `codex/gemini`. The most common issue is environment mismatch causing `cping` to fail. -> **🪟 全面支持 Windows — 通过 [WezTerm](https://wezfurlong.org/wezterm/)** -> WezTerm 现已成为所有平台的推荐终端。它是一个强大的跨平台终端,原生支持分屏。Linux/macOS 用户也推荐使用!当然短期tmux仍然支持。 +### 1) Prerequisites: Install Native WezTerm -- **⚡ 响应更快** — 优化了发送/接收延迟,显著快于 MCP -- **🐛 macOS 修复** — 修复了会话恢复和各种登录问题 -- **🔄 一键更新** — 运行 `ccb update` 即可更新,无需重新拉取安装 +- 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. -> 发现 bug?在项目目录运行 `claude` 调试,然后将 `git diff` 发给作者更新到主分支! +### 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. -传统 MCP 调用把 Codex 当作**无状态执行器**——Claude 每次都要传递完整上下文。 +### 4) Troubleshooting: `cping` Not Working -**ccb (Claude Code Bridge)** 建立**持久通道** 轻量级发送和抓取信息, AI间各自维护独立上下文。 +- **Most common:** Environment mismatch (ccb in WSL but codex in native Windows, or vice versa) +- **Codex session not running:** Run `ccb up codex` first +- **WezTerm CLI not found:** Ensure `wezterm` is in PATH +- **Terminal not refreshed:** Restart WezTerm after installation -### 分工协作 +--- -| 角色 | 职责 | -|------|------| -| **Claude Code** | 需求分析、架构规划、代码重构 | -| **Codex** | 算法实现、bug 定位、代码审查 | -| **Gemini** | 研究、多角度分析、验证 | -| **ccb** | 会话管理、上下文隔离、通信桥接 | +## 🍎 macOS Installation Guide -### 官方 MCP vs 持久双窗口 +### Command Not Found After Installation -| 维度 | MCP(官方方案) | 持久双窗口 | -|------|----------------|-----------| -| Codex 状态 | 无记忆 | 持久会话 | -| 上下文 | Claude 传递 | 各自维护 | -| Token 消耗 | 5k-20k/次 | 50-200/次(速度显著提升) | -| 工作模式 | 主从 | 并行协作 | -| 会话恢复 | 不支持 | 支持 (`-r`) | -| 多AI | 单目标 | 多后端 | +If `ccb`, `cask`, `cping` commands are not found after running `./install.sh install`: -> **偏好 MCP?** 推荐 [CodexMCP](https://github.com/GuDaStudio/codexmcp) — 更强大的 MCP 实现,支持会话上下文和多轮对话。 +**Cause:** The install directory (`~/.local/bin`) is not in your PATH. -
-Token 节省原理 +**Solution:** -``` -MCP 方式: - Claude → [完整代码 + 历史 + 指令] → Codex - 消耗:5,000-20,000 tokens/次 - -双窗口方式(每次仅发送和抓取少量信息): - Claude → "优化 utils.py" → Codex - 消耗:50-200 tokens/次 - (Codex 自己读取文件) -``` +```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 -```bash -git clone https://github.com/bfly123/claude_code_bridge.git -cd claude_code_bridge -./install.sh install -``` +# 4. If not configured, add manually +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc -### Windows +# 5. Reload config +source ~/.zshrc +``` -- **推荐 WSL2:** 在 WSL 内执行上面的命令即可。 -- **原生 Windows(PowerShell/CMD):** 使用包装脚本: - - `install.cmd install` - - 或 `powershell -ExecutionPolicy Bypass -File .\\install.ps1 install`(会提示选择 `BackendEnv`) -- **仅 WezTerm(无 tmux):** 建议在 WezTerm 中运行 `ccb`,或设置 `CCB_TERMINAL=wezterm`(必要时设置 `CODEX_WEZTERM_BIN=wezterm.exe`)。 +### WezTerm Not Detecting Commands -### BackendEnv(Windows/WSL 必看) +If WezTerm cannot find ccb commands but regular Terminal can: -`ccb/cask-w` 必须和 `codex/gemini` 在同一环境运行,否则可能出现: -- `exit code 127`(找不到命令) -- `cpend/gpend` 读不到回复(会话目录不同) +- WezTerm may use a different shell config +- Add PATH to `~/.zprofile` as well: -安装时的选择会写入 `ccb_config.json`(也可用环境变量 `CCB_BACKEND_ENV` 覆盖): -- `BackendEnv=wsl`:codex/gemini 安装并运行在 WSL(如果你是在 WSL 里直接输入 `codex` 的场景,推荐选这个) -- `BackendEnv=windows`:codex/gemini 为 Windows 原生 CLI +```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. -```bash -ccb up codex # 启动 Codex -ccb up gemini # 启动 Gemini -ccb up codex gemini # 同时启动 -ccb up codex -r # 恢复上次会话 -ccb up codex -a # 最高权限模式 -``` +**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."* -```bash -ccb status # 检查后端状态 -ccb kill codex # 终止会话 -ccb restore codex # 连接到运行中的会话 -ccb update # 更新到最新版本 -``` +### 🎴 Fun & Creative: AI Poker Night! -> `-a` 为 Claude 启用 `--dangerously-skip-permissions`,Codex 启用 `--full-auto`。 -> `-r` 基于当前目录下的本地文件恢复(`.claude-session/.codex-session/.gemini-session`);删除这些文件即可重置。 +> *"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. -### 实用场景 -- "让 Codex 审查我的代码修改" -- "问问 Gemini 有没有其他方案" -- "Codex 规划重构方案,我来实现它监督" -- "Codex 写后端 API,我写前端" +--- -### 趣味玩法 +## 📝 Command Reference -> **🎴 特色玩法:AI 棋牌之夜!** -> ``` -> "让 Claude、Codex 和 Gemini 来一局斗地主! -> 你来发牌,大家明牌玩!" -> -> 🃏 Claude (地主) vs 🎯 Codex + 💎 Gemini (农民) -> ``` +### Codex Commands -- "和 Codex 下五子棋" -- "辩论:Tab vs 空格" -- "Codex 写函数,Claude 找 bug" +| Command | Description | +| :--- | :--- | +| `/cask ` | Background mode: Submit task to Codex, free to continue other tasks (recommended) | +| `/cask-w ` | Foreground mode: Submit task and wait for response, faster but blocking | +| `cpend [N]` | Fetch Codex conversation history, N controls rounds (default 1) | +| `cping` | Test Codex connectivity | -### 进阶工作流 -- "Codex 设计架构,Claude 实现各模块" -- "两个 AI 从不同角度并行 Code Review" -- "Codex 实现,Gemini 审查,Claude 协调" +### Gemini Commands -## 命令(开发者使用) +| Command | Description | +| :--- | :--- | +| `/gask ` | Background mode: Submit task to Gemini | +| `/gask-w ` | Foreground mode: Submit task and wait for response | +| `gpend [N]` | Fetch Gemini conversation history | +| `gping` | Test Gemini connectivity | -> 普通用户无需使用这些命令——Claude 会自动检测协作意图。 +--- -**Codex:** +## 🖥️ Editor Integration: Neovim + Multi-AI Review -| 命令 | 说明 | -|------|------| -| `cask-w <消息>` | 同步:等待回复 | -| `cask <消息>` | 异步:发送即返回 | -| `cpend` | 查看最新回复 | -| `cping` | 测试连通性 | +Neovim integration with multi-AI code review -**Gemini:** +> 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. -| 命令 | 说明 | -|------|------| -| `gask-w <消息>` | 同步:等待回复 | -| `gask <消息>` | 异步:发送即返回 | -| `gpend` | 查看最新回复 | -| `gping` | 测试连通性 | +--- -## 依赖 +## 📋 Requirements -- Python 3.10+ -- tmux 或 WezTerm(至少安装一个),强烈推荐wezterm +- **Python 3.10+** +- **Terminal:** [WezTerm](https://wezfurlong.org/wezterm/) (Highly Recommended) or tmux +--- -## 卸载 +## 🗑️ Uninstall ```bash ./install.sh uninstall @@ -383,12 +357,59 @@ ccb update # 更新到最新版本
-**WSL2 supported** | WSL1 not supported (FIFO limitation) +**Windows fully supported** (WSL + Native via WezTerm) --- -**测试用户群,欢迎大佬们** +**Join our community** -WeChat Group +📧 Email: bfly123@126.com +💬 WeChat: seemseam-com + +WeChat Group
+ +--- + +
+Version History + +### 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 +- Simplify CCA detection: check for `.autoflow` folder in current directory +- Plan mode enabled for CCA 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 CCA (Claude Code 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/README_zh.md b/README_zh.md new file mode 100644 index 0000000..881fdee --- /dev/null +++ b/README_zh.md @@ -0,0 +1,417 @@ +
+ +# Claude Code Bridge (ccb) v3.0.0 + +**基于终端分屏的 Claude & Codex & Gemini 丝滑协作工具** + +**打造真实的大模型专家协作团队,给 Claude Code / Codex / Gemini / OpenCode 配上"不会遗忘"的搭档** + +

+ 交互皆可见 + 模型皆可控 +

+

+ Every Interaction Visible + Every Model Controllable +

+ +[![Version](https://img.shields.io/badge/version-3.0.0-orange.svg)]() +[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)]() + +[English](README.md) | **中文** + +双窗口协作演示 + +
+ +--- + +**简介:** 多模型协作能够有效避免模型偏见、认知漏洞和上下文限制,然而 MCP、Skills 等直接调用 API 方式存在诸多局限性。本项目打造了一套新的方案。 + +## ⚡ 核心优势 + +| 特性 | 价值 | +| :--- | :--- | +| **🖥️ 可见可控** | 多模型分屏 CLI 挂载,所见即所得,完全掌控。 | +| **🧠 持久上下文** | 每个 AI 独立记忆,关闭后可随时恢复(`-r` 参数)。 | +| **📉 节省 Token** | 仅发送轻量级指令,而非整个代码库历史 (~20k tokens)。 | +| **🪟 原生终端体验** | 直接集成于 **WezTerm** (推荐) 或 tmux,无需配置复杂的服务器。 | + +--- + +

🚀 v3.0 新版本特性

+ +> **跨 AI 协作的终极桥梁** + +v3.0 带来了革命性的 **智能守护进程 (Smart Daemons)** 架构,实现了并行执行、跨 Agent 协调和企业级稳定性。 + +
+ +![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) + +
+ +

✨ 核心特性

+ +- **🔄 真·并行**: 同时提交多个任务给 Codex、Gemini 或 OpenCode。新的守护进程 (`caskd`, `gaskd`, `oaskd`) 会自动将它们排队并串行执行,确保上下文不被污染。 +- **🤝 跨 AI 编排**: Claude 和 Codex 现在可以同时驱动 OpenCode Agent。所有请求都由统一的守护进程层仲裁。 +- **🛡️ 坚如磐石**: 守护进程自我管理——首个请求自动启动,空闲 60 秒后自动关闭以节省资源。 +- **⚡ 链式调用**: 支持高级工作流!Codex 可以自主调用 `oask` 将子任务委派给 OpenCode 模型。 +- **🛑 智能打断**: Gemini 任务支持智能打断检测,自动处理停止信号并确保工作流连续性。 + +

🧩 功能支持矩阵

+ +| 特性 | `caskd` (Codex) | `gaskd` (Gemini) | `oaskd` (OpenCode) | +| :--- | :---: | :---: | :---: | +| **并行队列** | ✅ | ✅ | ✅ | +| **打断感知** | ✅ | ✅ | - | +| **响应隔离** | ✅ | ✅ | ✅ | + +
+📊 查看真实压力测试结果 + +
+ +**场景 1: Claude & Codex 同时访问 OpenCode** +*两个 Agent 同时发送请求,由守护进程完美协调。* + +| 来源 | 任务 | 结果 | 状态 | +| :--- | :--- | :--- | :---: | +| 🤖 Claude | `CLAUDE-A` | **CLAUDE-A** | 🟢 | +| 🤖 Claude | `CLAUDE-B` | **CLAUDE-B** | 🟢 | +| 💻 Codex | `CODEX-A` | **CODEX-A** | 🟢 | +| 💻 Codex | `CODEX-B` | **CODEX-B** | 🟢 | + +**场景 2: 递归/链式调用** +*Codex 自主驱动 OpenCode 执行 5 步工作流。* + +| 请求 | 退出码 | 响应 | +| :--- | :---: | :--- | +| **ONE** | `0` | `CODEX-ONE` | +| **TWO** | `0` | `CODEX-TWO` | +| **THREE** | `0` | `CODEX-THREE` | +| **FOUR** | `0` | `CODEX-FOUR` | +| **FIVE** | `0` | `CODEX-FIVE` | + +
+ +--- + +

🧠 介绍 CCA (Claude Code Autoflow)

+ +释放 `ccb` 的全部潜力 —— **CCA** 是基于本桥接工具构建的高级工作流自动化系统。 + +* **工作流自动化**: 智能任务分配和自动化状态管理。 +* **无缝集成**: 原生支持 v3.0 守护进程架构。 + +[👉 在 GitHub 上查看项目](https://github.com/bfly123/claude_code_autoflow) + +**通过 CCB 安装:** +```bash +ccb update cca +``` + +--- + +## 🚀 快速开始 + +**第一步:** 安装 [WezTerm](https://wezfurlong.org/wezterm/)(Windows 请安装原生 `.exe` 版本) + +**第二步:** 根据你的环境选择安装脚本: + +
+Linux + +```bash +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install +``` + +
+ +
+macOS + +```bash +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install +``` + +> **注意:** 如果安装后找不到命令,请参考 [macOS 故障排除](#-macos-安装指南)。 + +
+ +
+WSL (Windows 子系统) + +> 如果你的 Claude/Codex/Gemini 运行在 WSL 中,请使用此方式。 + +> **⚠️ 警告:** 请勿使用 root/管理员权限安装或运行 ccb。请先切换到普通用户(`su - 用户名` 或使用 `adduser` 创建新用户)。 + +```bash +# 在 WSL 终端中运行(使用普通用户,不要用 root) +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install +``` + +
+ +
+Windows 原生 + +> 如果你的 Claude/Codex/Gemini 运行在 Windows 原生环境,请使用此方式。 + +```powershell +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +powershell -ExecutionPolicy Bypass -File .\install.ps1 install +``` + +
+ +### 启动 +```bash +ccb up codex # 启动 Codex +ccb up gemini # 启动 Gemini +ccb up opencode # 启动 OpenCode +ccb up codex gemini # 同时启动两个 +ccb up codex gemini opencode # 同时启动三个 +ccb-layout # 启动 2x2 四 AI 布局(Codex+Gemini+OpenCode) +``` + +### 常用参数 +| 参数 | 说明 | 示例 | +| :--- | :--- | :--- | +| `-r` | 恢复上次会话上下文 | `ccb up codex -r` | +| `-a` | 全自动模式,跳过权限确认 | `ccb up codex -a` | +| `-h` | 查看详细帮助信息 | `ccb -h` | +| `-v` | 查看当前版本和检测更新 | `ccb -v` | + +### 后续更新 +```bash +ccb update # 更新 ccb 到最新版本 +``` + +--- + +## 🪟 Windows 安装指南(WSL vs 原生) + +> 结论先说:`ccb/cask-w/cping` 必须和 `codex/gemini` 跑在**同一个环境**(WSL 就都在 WSL,原生 Windows 就都在原生 Windows)。最常见问题就是装错环境导致 `cping` 不通。 + +### 1) 前置条件:安装原生版 WezTerm(不是 WSL 版) + +- 请安装 Windows 原生 WezTerm(官网 `.exe` / winget 安装都可以),不要在 WSL 里安装 Linux 版 WezTerm。 +- 原因:`ccb` 在 WezTerm 模式下依赖 `wezterm cli` 管理窗格;使用 Windows 原生 WezTerm 最稳定,也最符合本项目的“分屏多模型协作”设计。 + +### 2) 判断方法:你到底是在 WSL 还是原生 Windows? + +优先按“**你是通过哪种方式安装并运行 Claude Code/Codex**”来判断: + +- **WSL 环境特征** + - 你在 WSL 终端(Ubuntu/Debian 等)里用 `bash` 安装/运行(例如 `curl ... | bash`、`apt`、`pip`、`npm` 安装后在 Linux shell 里执行)。 + - 路径通常长这样:`/home//...`,并且可能能看到 `/mnt/c/...`。 + - 可辅助确认:`cat /proc/version | grep -i microsoft` 有输出,或 `echo $WSL_DISTRO_NAME` 非空。 +- **原生 Windows 环境特征** + - 你在 Windows Terminal / WezTerm / PowerShell / CMD 里安装/运行(例如 `winget`、PowerShell 安装脚本、Windows 版 `codex.exe`),并用 `powershell`/`cmd` 启动。 + - 路径通常长这样:`C:\\Users\\\\...`,并且 `where codex`/`where claude` 返回的是 Windows 路径。 + +### 3) WSL 用户指南(推荐:WezTerm 承载,计算与工具在 WSL) + +#### 3.1 让 WezTerm 启动时自动进入 WSL + +在 Windows 上编辑 WezTerm 配置文件(通常是 `%USERPROFILE%\\.wezterm.lua`),设置默认进入某个 WSL 发行版: + +```lua +local wezterm = require 'wezterm' + +return { + default_domain = 'WSL:Ubuntu', -- 把 Ubuntu 换成你的发行版名 +} +``` + +发行版名可在 PowerShell 里用 `wsl -l -v` 查看(例如 `Ubuntu-22.04`)。 + +#### 3.2 在 WSL 中运行 `install.sh` 安装 + +在 WezTerm 打开的 WSL shell 里执行: + +```bash +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +./install.sh install +``` + +提示: +- 后续所有 `ccb/cask/cask-w/cping` 也都请在 **WSL** 里运行(和你的 `codex/gemini` 保持一致)。 + +#### 3.3 安装后如何测试(`cping`) + +```bash +ccb up codex +cping +``` + +预期看到类似 `Codex connection OK (...)` 的输出;失败会提示缺失项(例如窗格不存在、会话目录缺失等)。 + +### 4) 原生 Windows 用户指南(WezTerm 承载,工具也在 Windows) + +#### 4.1 在原生 Windows 中运行 `install.ps1` 安装 + +在 PowerShell 里执行: + +```powershell +git clone https://github.com/bfly123/claude_code_bridge.git +cd claude_code_bridge +powershell -ExecutionPolicy Bypass -File .\install.ps1 install +``` + +提示: +- 安装脚本会明确提醒“`ccb/cask-w` 必须与 `codex/gemini` 在同一环境运行”,请确认你打算在原生 Windows 运行 `codex/gemini`。 + +#### 4.2 安装后如何测试 + +```powershell +ccb up codex +cping +``` + +同样预期看到 `Codex connection OK (...)`。 + +### 5) 常见问题(尤其是 `cping` 不通) + +#### 5.1 打开 ccb 后无法 ping 通 Codex 的原因 + +- **最主要原因:搞错 WSL 和原生环境(装/跑不在同一侧)** + - 例子:你在 WSL 里装了 `ccb`,但 `codex` 在原生 Windows 跑;或反过来。此时两边的路径、会话目录、管道/窗格检测都对不上,`cping` 大概率失败。 +- **Codex 会话并没有启动或已退出** + - 先执行 `ccb up codex`,并确认 Codex 对应的 WezTerm 窗格还存在、没有被手动关闭。 +- **WezTerm CLI 不可用或找不到** + - `ccb` 在 WezTerm 模式下需要调用 `wezterm cli list` 等命令;如果 `wezterm` 不在 PATH,或 WSL 里找不到 `wezterm.exe`,会导致检测失败(可重开终端或按提示配置 `CODEX_WEZTERM_BIN`)。 +- **PATH/终端未刷新** + - 安装后请重启终端(WezTerm),再运行 `ccb`/`cping`。 + +--- + +## 🍎 macOS 安装指南 + +### 安装后找不到命令 + +如果运行 `./install.sh install` 后找不到 `ccb`、`cask`、`cping` 等命令: + +**原因:** 安装目录 (`~/.local/bin`) 不在 PATH 中。 + +**解决方法:** + +```bash +# 1. 检查安装目录是否存在 +ls -la ~/.local/bin/ + +# 2. 检查 PATH 是否包含该目录 +echo $PATH | tr ':' '\n' | grep local + +# 3. 检查 shell 配置(macOS 默认使用 zsh) +cat ~/.zshrc | grep local + +# 4. 如果没有配置,手动添加 +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc + +# 5. 重新加载配置 +source ~/.zshrc +``` + +### WezTerm 中找不到命令 + +如果普通 Terminal 能找到命令,但 WezTerm 找不到: + +- WezTerm 可能使用不同的 shell 配置文件 +- 同时添加 PATH 到 `~/.zprofile`: + +```bash +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zprofile +``` + +然后完全重启 WezTerm(Cmd+Q 退出后重新打开)。 + +--- + +## 🗣️ 使用场景 + +安装完成后,直接用自然语言与 Claude 对话即可,它会自动检测并分派任务。 + +**常见用法:** + +- **代码审查**:*"让 Codex 帮我 Review 一下 `main.py` 的改动。"* +- **多维咨询**:*"问问 Gemini 有没有更好的实现方案。"* +- **结对编程**:*"Codex 负责写后端逻辑,我来写前端。"* +- **架构设计**:*"让 Codex 先设计一下这个模块的结构。"* +- **信息交互**:*"调取 Codex 3 轮对话,并加以总结"* + +### 🎴 趣味玩法:AI 棋牌之夜! + +> *"让 Claude、Codex 和 Gemini 来一局斗地主!你来发牌,大家明牌玩!"* +> +> 🃏 Claude (地主) vs 🎯 Codex + 💎 Gemini (农民) + +> **提示:** 底层命令 (`cask`, `cping` 等) 通常由 Claude 自动调用,需要显式调用见命令详情。 + +--- + +## 📝 命令详情 + +### Codex 命令 + +| 命令 | 说明 | +| :--- | :--- | +| `/cask <消息>` | 后台模式:提交任务给 Codex,前台释放可继续其他任务(推荐) | +| `/cask-w <消息>` | 前台模式:提交任务并等待返回,响应更快但会阻塞 | +| `cpend [N]` | 调取当前 Codex 会话的对话记录,N 控制轮数(默认 1) | +| `cping` | 测试 Codex 连通性 | + +### Gemini 命令 + +| 命令 | 说明 | +| :--- | :--- | +| `/gask <消息>` | 后台模式:提交任务给 Gemini | +| `/gask-w <消息>` | 前台模式:提交任务并等待返回 | +| `gpend [N]` | 调取当前 Gemini 会话的对话记录 | +| `gping` | 测试 Gemini 连通性 | + +--- + +## 🖥️ 编辑器集成:Neovim + 多模型代码审查 + +Neovim 集成多模型代码审查 + +> 结合 **Neovim** 等编辑器,实现无缝的代码编辑与多模型审查工作流。在你喜欢的编辑器中编写代码,AI 助手实时审查并提供改进建议。 + +--- + +## 📋 环境要求 + +- **Python 3.10+** +- **终端软件:** [WezTerm](https://wezfurlong.org/wezterm/) (强烈推荐) 或 tmux + +--- + +## 🗑️ 卸载 + +```bash +./install.sh uninstall +``` + +--- + +
+更新历史 + +### v3.0.0 +- **智能守护进程**: `caskd`/`gaskd`/`oaskd` 支持 60秒空闲超时和并行队列 +- **跨 AI 协作**: 支持多个 Agent (Claude/Codex) 同时调用同一个 Agent (OpenCode) +- **打断检测**: Gemini 现在支持智能打断处理 +- **链式执行**: Codex 可以调用 `oask` 驱动 OpenCode +- **稳定性**: 健壮的队列管理和锁文件机制 diff --git a/assets/figure.png b/assets/figure.png deleted file mode 100644 index 39fda49..0000000 Binary files a/assets/figure.png and /dev/null differ diff --git a/assets/nvim.png b/assets/nvim.png new file mode 100644 index 0000000..b22e4de Binary files /dev/null and b/assets/nvim.png differ diff --git a/assets/wechat.jpg b/assets/wechat.jpg deleted file mode 100644 index b907ca2..0000000 Binary files a/assets/wechat.jpg and /dev/null differ diff --git a/assets/weixin.png b/assets/weixin.png new file mode 100644 index 0000000..89e9efa Binary files /dev/null and b/assets/weixin.png differ diff --git a/bin/cask b/bin/cask index 27a053c..4b86c35 100755 --- a/bin/cask +++ b/bin/cask @@ -1,79 +1,393 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -cask - 将消息转发到 Codex 会话 +cask - Send message to Codex and wait for reply (sync). + +Designed to be used with Claude Code's run_in_background=true. +If --output is provided, the reply is written atomically to that file and stdout stays empty. """ from __future__ import annotations import json import os +import socket import sys +import time from pathlib import Path from typing import Optional, Tuple script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() +from process_lock import ProviderLock -from terminal import get_backend_for_session, get_pane_id_from_session +COMPLETION_MARKER = (os.environ.get("CCB_EXECUTION_COMPLETE_MARKER") or "EXECUTION_COMPLETE").strip() or "EXECUTION_COMPLETE" +from env_utils import env_bool +from ccb_protocol import REQ_ID_PREFIX, make_req_id, strip_done_text, wrap_codex_prompt +from caskd_daemon import read_state +from askd_client import ( + autostart_enabled, + state_file_from_env, + find_project_session_file, + maybe_start_daemon, + wait_for_daemon_ready, + check_background_mode, +) +from providers import CASK_CLIENT_SPEC -def _usage() -> None: - print("用法: cask <消息>", file=sys.stderr) - - -def _load_session() -> Optional[dict]: - env_session = os.environ.get("CODEX_TMUX_SESSION") - env_pane = os.environ.get("CODEX_WEZTERM_PANE") - if env_session or env_pane: - return { - "terminal": "wezterm" if env_pane else "tmux", - "pane_id": env_pane, - "tmux_session": env_session, - "runtime_dir": os.environ.get("CODEX_RUNTIME_DIR"), - } +SUPERVISOR_PROMPT = """## Executor Mode: codex+opencode +You are the SUPERVISOR, NOT the executor. +- Do NOT directly edit repo files yourself. +- Break down tasks into clear instructions for OpenCode. +- Use oask to delegate execution to OpenCode. +- Review OpenCode results and iterate if needed. + +""" + + +def _get_executor_from_roles() -> Optional[str]: + """Read executor from roles config (priority: session > project > global).""" + candidates = [ + Path(".autoflow/roles.session.json"), + Path(".autoflow/roles.json"), + Path.home() / ".config" / "cca" / "roles.json", + ] + xdg_config = (os.environ.get("XDG_CONFIG_HOME") or "").strip() + if xdg_config: + candidates.append(Path(xdg_config) / "cca" / "roles.json") + if os.name == "nt": + appdata = (os.environ.get("APPDATA") or "").strip() + if appdata: + candidates.append(Path(appdata) / "cca" / "roles.json") + localappdata = (os.environ.get("LOCALAPPDATA") or "").strip() + if localappdata: + candidates.append(Path(localappdata) / "cca" / "roles.json") + for cfg_path in candidates: + if cfg_path.exists(): + try: + data = json.loads(cfg_path.read_text(encoding="utf-8")) + executor = data.get("executor") + if executor: + return executor + except Exception: + pass + return None + + +def _has_completion_marker(text: str) -> bool: + """Check if last line contains COMPLETION_MARKER (flexible match).""" + lines = text.rstrip().splitlines() + return bool(lines) and COMPLETION_MARKER in lines[-1] + + +def _strip_completion_marker(text: str) -> str: + """Remove the trailing marker line if it contains COMPLETION_MARKER.""" + lines = text.rstrip().splitlines() + if lines and COMPLETION_MARKER in lines[-1]: + lines[-1] = lines[-1].replace(COMPLETION_MARKER, "").rstrip() + if not lines[-1]: + lines = lines[:-1] + return "\n".join(lines).rstrip() + return text.rstrip() + + +def _wait_for_complete_reply(log_reader, state: dict, timeout: float, quiet: bool): + """Wait until reply ends with COMPLETION_MARKER or timeout.""" + from cli_output import EXIT_NO_REPLY, EXIT_OK + + # Pre-check: try to read existing messages (may have arrived before we started) + existing_reply = None + try: + existing_reply, state = log_reader.wait_for_message(state, timeout=0.1) + if existing_reply and _has_completion_marker(existing_reply): + return _strip_completion_marker(existing_reply), EXIT_OK + except Exception: + pass - session_file = Path.cwd() / ".codex-session" - if not session_file.exists(): + deadline = time.time() + timeout + chunks = [] + if existing_reply: + chunks.append(existing_reply) + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + reply, state = log_reader.wait_for_message(state, remaining) + if reply is None: + continue + chunks.append(reply) + if _has_completion_marker(reply): + combined = "\n".join(chunks) + return _strip_completion_marker(combined), EXIT_OK + + if chunks: + if not quiet: + print("[WARN] Marker not detected, returning partial reply", file=sys.stderr) + combined = "\n".join(chunks) + return _strip_completion_marker(combined), EXIT_NO_REPLY + return None, EXIT_NO_REPLY + + +def _wait_for_done_reply(log_reader, state: dict, timeout: float, req_id: str, quiet: bool): + from cli_output import EXIT_NO_REPLY, EXIT_OK + deadline = time.time() + timeout + chunks: list[str] = [] + anchor_seen = False + anchor_collect_grace = min(deadline, time.time() + 2.0) + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + event, state = log_reader.wait_for_event(state, min(remaining, 0.5)) + if event is None: + continue + role, text = event + if role == "user": + if f"{REQ_ID_PREFIX} {req_id}" in text: + anchor_seen = True + continue + if role != "assistant": + continue + if (not anchor_seen) and time.time() < anchor_collect_grace: + continue + chunks.append(text) + combined = "\n".join(chunks) + from ccb_protocol import is_done_text + if is_done_text(combined, req_id): + return strip_done_text(combined, req_id), EXIT_OK + + combined = "\n".join(chunks) + if combined and not quiet: + print("[WARN] Done marker not detected, returning partial reply", file=sys.stderr) + return strip_done_text(combined, req_id), EXIT_NO_REPLY + + +def _with_completion_marker_request(message: str) -> str: + if not message: + return message + if COMPLETION_MARKER in message: + return message + return ( + f"{message}\n\n" + "IMPORTANT:\n" + "- You MUST reply with a final response (do not stay silent).\n" + f"- End your reply with this exact line (verbatim):\n{COMPLETION_MARKER}\n" + ) + + + +def _read_project_terminal(work_dir: Path) -> Optional[str]: + session_file = find_project_session_file(work_dir, CASK_CLIENT_SPEC.session_filename) + if not session_file: return None try: - data = json.loads(session_file.read_text(encoding="utf-8")) - if not data.get("active"): - return None - return data + data = json.loads(session_file.read_text(encoding="utf-8-sig")) except Exception: return None + if not isinstance(data, dict): + return None + terminal = str(data.get("terminal") or "").strip().lower() + if terminal: + return terminal + return None -def _resolve_session() -> Tuple[dict, str]: - data = _load_session() - if not data: - raise RuntimeError("❌ 未找到 Codex 会话,请先运行 ccb up codex") - pane_id = get_pane_id_from_session(data) - if not pane_id: - raise RuntimeError("❌ 会话配置无效") - return data, pane_id +def _try_daemon_request(work_dir: Path, message: str, timeout: float, quiet: bool) -> Optional[tuple[str, int]]: + if not env_bool("CCB_CASKD", True): + return None + terminal = _read_project_terminal(work_dir) + if terminal not in ("wezterm", "iterm2"): + return None + st = read_state(state_file=state_file_from_env(CASK_CLIENT_SPEC.state_file_env)) + if not st: + return None + try: + host = st.get("connect_host") or st.get("host") + port = int(st["port"]) + token = st["token"] + except Exception: + return None + try: + payload = { + "type": "cask.request", + "v": 1, + "id": f"cask-{os.getpid()}-{int(time.time() * 1000)}", + "token": token, + "work_dir": str(work_dir), + "timeout_s": float(timeout), + "quiet": bool(quiet), + "message": message, + } + connect_timeout = min(1.0, max(0.1, float(timeout))) + with socket.create_connection((host, port), timeout=connect_timeout) as sock: + sock.settimeout(0.5) + sock.sendall((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) + buf = b"" + deadline = time.time() + float(timeout) + 5.0 + while b"\n" not in buf and time.time() < deadline: + try: + chunk = sock.recv(65536) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + if b"\n" not in buf: + return None + line = buf.split(b"\n", 1)[0].decode("utf-8", errors="replace") + resp = json.loads(line) + if resp.get("type") != "cask.response": + return None + reply = str(resp.get("reply") or "") + exit_code = int(resp.get("exit_code", 1)) + return reply, exit_code + except Exception: + return None -def main(argv: list[str]) -> int: - if len(argv) <= 1: - _usage() - return 1 +def _maybe_start_caskd() -> bool: + """Start caskd daemon if conditions are met (cask-specific: requires wezterm/iterm2).""" + if not env_bool("CCB_CASKD", True): + return False + if not autostart_enabled(CASK_CLIENT_SPEC.autostart_env_primary, CASK_CLIENT_SPEC.autostart_env_legacy, True): + return False + terminal = _read_project_terminal(Path.cwd()) + if terminal not in ("wezterm", "iterm2"): + return False + return maybe_start_daemon(CASK_CLIENT_SPEC, Path.cwd()) - raw_command = " ".join(argv[1:]).strip() - if not raw_command: - _usage() - return 1 +def _wait_for_caskd_ready(timeout_s: float = 2.0) -> bool: + return wait_for_daemon_ready(CASK_CLIENT_SPEC, timeout_s, state_file_from_env(CASK_CLIENT_SPEC.state_file_env)) + +def _usage() -> None: + print("Usage: cask [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + + +def _parse_args(argv: list[str]) -> Tuple[Optional[Path], float, str, bool]: + output: Optional[Path] = None + timeout: Optional[float] = None + quiet = False + parts: list[str] = [] + + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + _usage() + raise SystemExit(0) + if token in ("-q", "--quiet"): + quiet = True + continue + if token in ("-o", "--output"): + try: + output = Path(next(it)).expanduser() + except StopIteration: + raise ValueError("--output requires a file path") + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + raise ValueError("--timeout requires a number") + except ValueError as exc: + raise ValueError(f"Invalid --timeout: {exc}") + continue + parts.append(token) + + message = " ".join(parts).strip() + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 + return output, timeout, message, quiet + + +def main(argv: list[str]) -> int: try: - data, pane_id = _resolve_session() - backend = get_backend_for_session(data) - if not backend: - raise RuntimeError("❌ 无法初始化终端后端") - if not backend.is_alive(pane_id): - terminal = data.get("terminal", "tmux") - raise RuntimeError(f"❌ {terminal} 会话不存在: {pane_id}\n提示: 请确认 ccb 正在运行") - backend.send_text(pane_id, raw_command) - print(f"✅ 已发送到 Codex ({pane_id})") - return 0 + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + from codex_comm import CodexCommunicator + + output_path, timeout, message, quiet = _parse_args(argv) + if not message and not sys.stdin.isatty(): + message = sys.stdin.read().strip() + if not message: + _usage() + return EXIT_ERROR + + # Strict mode: require run_in_background=true in Claude Code + if os.environ.get("CLAUDECODE") == "1" and not check_background_mode(): + print("[ERROR] cask MUST be called with run_in_background=true", file=sys.stderr) + print("Correct usage: Bash(cask \"...\", run_in_background=true)", file=sys.stderr) + return EXIT_ERROR + + # Check if supervisor mode is enabled via roles config + executor = _get_executor_from_roles() + if executor == "codex+opencode": + message = SUPERVISOR_PROMPT + message + + # Prefer daemon if available (scheme-2). Do NOT take the client-side lock for daemon mode: + # daemon performs per-session serialization and should accept concurrent client submissions. + daemon_result = _try_daemon_request(Path.cwd(), message, timeout, quiet) + if daemon_result is None and _maybe_start_caskd(): + # Give daemon a moment to write state file and start listening, then retry once. + _wait_for_caskd_ready(timeout_s=min(2.0, max(0.2, float(timeout)))) + daemon_result = _try_daemon_request(Path.cwd(), message, timeout, quiet) + if daemon_result is not None: + reply, exit_code = daemon_result + if output_path: + pending_path = Path(str(output_path) + ".pending") + atomic_write_text(pending_path, reply + "\n") + pending_path.replace(output_path) + return exit_code + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + + comm = CodexCommunicator(lazy_init=True) + + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + raise RuntimeError(f"[ERROR] Session error: {status}") + + # Direct mode: keep client-side lock to avoid log mis-association and interleaved injection. + lock_key = (comm.session_info.get("pane_title_marker") or comm.pane_id or comm.session_info.get("codex_session_id") or os.getcwd()) + lock = ProviderLock("codex", cwd=f"session:{lock_key}", timeout=min(300.0, max(1.0, float(timeout)))) + if not lock.acquire(): + print("[ERROR] Another cask request is in progress (lock timeout).", file=sys.stderr) + return EXIT_ERROR + + try: + # Reset log state to ignore any messages before lock acquisition + comm.log_reader.capture_state() + + # Direct mode: scheme-2 req_id protocol (strict). + req_id = make_req_id() + _, state = comm._send_message(wrap_codex_prompt(message, req_id)) + reply, exit_code = _wait_for_done_reply(comm.log_reader, state, timeout, req_id, quiet) + if reply is None: + if not quiet: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return exit_code + + if output_path: + pending_path = Path(str(output_path) + ".pending") + atomic_write_text(pending_path, reply + "\n") + pending_path.replace(output_path) + return exit_code + finally: + lock.release() + + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + except KeyboardInterrupt: + return 130 except Exception as exc: print(exc, file=sys.stderr) return 1 diff --git a/bin/cask-w b/bin/cask-w index 822cc7b..52f3585 100755 --- a/bin/cask-w +++ b/bin/cask-w @@ -1,36 +1,102 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -cask-w - 同步发送消息到 Codex 并等待回复 +cask-w - Send message to Codex and wait for reply (foreground sync). + +stdout: reply text only (for piping). +stderr: progress and errors. """ from __future__ import annotations +import os import sys from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) - -from codex_comm import CodexCommunicator +from compat import setup_windows_encoding +setup_windows_encoding() def main(argv: list[str]) -> int: + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + from codex_comm import CodexCommunicator + from i18n import t + if len(argv) <= 1: - print("用法: cask-w <消息>", file=sys.stderr) - return 1 + print("Usage: cask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_ERROR - message = " ".join(argv[1:]).strip() + output_path: Path | None = None + timeout: float | None = None + + parts: list[str] = [] + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + print("Usage: cask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_OK + if token in ("-o", "--output"): + try: + output_path = Path(next(it)).expanduser() + except StopIteration: + print("[ERROR] --output requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + print("[ERROR] --timeout requires a number", file=sys.stderr) + return EXIT_ERROR + except ValueError: + print("[ERROR] --timeout must be a number", file=sys.stderr) + return EXIT_ERROR + continue + parts.append(token) + + message = " ".join(parts).strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) - return 1 + print("[ERROR] Message cannot be empty", file=sys.stderr) + return EXIT_ERROR + + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 try: - comm = CodexCommunicator() - reply = comm.ask_sync(message, timeout=0) - return 0 if reply else 1 + comm = CodexCommunicator(lazy_init=True) + + # Check session health + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + print(f"[ERROR] Session error: {status}", file=sys.stderr) + return EXIT_ERROR + + # Send message + print(f"[INFO] {t('sending_to', provider='Codex')}", file=sys.stderr, flush=True) + _, state = comm._send_message(message) + + message_reply, _ = comm.log_reader.wait_for_message(state, timeout) + if not message_reply: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return EXIT_NO_REPLY + + if output_path: + atomic_write_text(output_path, message_reply + "\n") + + sys.stdout.write(message_reply) + if not message_reply.endswith("\n"): + sys.stdout.write("\n") + return EXIT_OK + except KeyboardInterrupt: + print("[ERROR] Interrupted", file=sys.stderr) + return 130 except Exception as exc: - print(exc, file=sys.stderr) - return 1 + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_ERROR if __name__ == "__main__": diff --git a/bin/caskd b/bin/caskd new file mode 100755 index 0000000..c8dc7b8 --- /dev/null +++ b/bin/caskd @@ -0,0 +1,54 @@ +#!/usr/bin/env python +""" +caskd - Codex ask daemon (WezTerm/iTerm2). + +Implements Scheme-2: per-session serialized sending + log-driven reply extraction with req_id. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) + +from compat import setup_windows_encoding + +setup_windows_encoding() + +from caskd_daemon import CaskdServer, shutdown_daemon + + +def _parse_listen(value: str) -> tuple[str, int]: + value = (value or "").strip() + if not value: + return "127.0.0.1", 0 + if ":" not in value: + return value, 0 + host, port_s = value.rsplit(":", 1) + return host or "127.0.0.1", int(port_s or "0") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="cask daemon (Codex, WezTerm)") + ap.add_argument("--listen", default=os.environ.get("CCB_CASKD_LISTEN", "127.0.0.1:0"), help="host:port (default 127.0.0.1:0)") + ap.add_argument("--state-file", default=os.environ.get("CCB_CASKD_STATE_FILE", ""), help="Override state file path") + ap.add_argument("--shutdown", action="store_true", help="Shutdown running daemon") + args = ap.parse_args(argv[1:]) + + state_file = Path(args.state_file).expanduser() if args.state_file else None + + if args.shutdown: + ok = shutdown_daemon(state_file=state_file) + return 0 if ok else 1 + + host, port = _parse_listen(args.listen) + server = CaskdServer(host=host, port=port, state_file=state_file) + return server.serve_forever() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/ccb-layout b/bin/ccb-layout new file mode 100644 index 0000000..dcc12ed --- /dev/null +++ b/bin/ccb-layout @@ -0,0 +1,45 @@ +#!/usr/bin/env python +""" +ccb-layout - Start a 2x2 four-AI layout (Claude + Codex + Gemini + OpenCode). + +Implementation: delegate to `ccb up codex gemini opencode`. +- WezTerm/iTerm2: ccb uses a 2x2 grid when all three backends are present. +- tmux: backends are started as separate tmux sessions (no single-window 2x2 grid). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +repo_root = script_dir.parent + + +def _resolve_ccb() -> str: + on_path = shutil.which("ccb") + if on_path: + return on_path + candidate = repo_root / "ccb" + if candidate.exists(): + return str(candidate) + return "ccb" + + +def main(argv: list[str]) -> int: + if len(argv) > 1 and argv[1] in ("-h", "--help"): + print("Usage: ccb-layout [-r] [-a] [--no-claude]", file=sys.stderr) + return 0 + + ccb_cmd = _resolve_ccb() + cmd = [ccb_cmd, "up", "codex", "gemini", "opencode", *argv[1:]] + try: + return subprocess.run(cmd).returncode + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/cpend b/bin/cpend index b971983..aeea377 100755 --- a/bin/cpend +++ b/bin/cpend @@ -1,78 +1,149 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -cpend - 查看 Codex 最新回复 +cpend - View latest Codex reply """ import json +import os import sys from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() + +from i18n import t try: - from codex_comm import CodexCommunicator + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK + from codex_comm import CodexLogReader, SESSION_ID_PATTERN + from pane_registry import load_registry_by_session_id, load_registry_by_claude_pane except ImportError as exc: - print(f"导入失败: {exc}") + print(f"Import failed: {exc}") sys.exit(1) +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("CPEND_DEBUG") in ("1", "true", "yes")) + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + -def _load_pending_state() -> dict: - """从 .codex-session 加载 cask-w 超时时保存的状态""" +def _load_session_log_path() -> tuple[Path | None, str | None]: + """Load codex_session_path from .codex-session if exists""" session_file = Path.cwd() / ".codex-session" if not session_file.exists(): - return {} + return None, None try: - with session_file.open("r", encoding="utf-8") as f: + with session_file.open("r", encoding="utf-8-sig") as f: data = json.load(f) - return data.get("pending_state", {}) - except Exception: - return {} + path_str = data.get("codex_session_path") + session_id = data.get("codex_session_id") + if path_str: + return Path(path_str).expanduser(), session_id + except Exception as exc: + _debug(f"Failed to read .codex-session ({session_file}): {exc}") + return None, None -def _clear_pending_state() -> None: - """清除 pending_state""" - session_file = Path.cwd() / ".codex-session" - if not session_file.exists(): - return +def _load_registry_log_path() -> tuple[Path | None, dict | None]: + session_id = (os.environ.get("CODEX_SESSION_ID") or "").strip() + if session_id: + record = load_registry_by_session_id(session_id) + if record: + path_str = record.get("codex_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by CODEX_SESSION_ID: {path}") + return path, record + + pane_id = (os.environ.get("WEZTERM_PANE") or "").strip() + if pane_id: + record = load_registry_by_claude_pane(pane_id) + if record: + path_str = record.get("codex_session_path") + if path_str: + path = Path(path_str).expanduser() + _debug(f"Using registry by WEZTERM_PANE: {path}") + return path, record + return None, None + + +def _derive_session_filter(log_path: Path | None, registry_record: dict | None, session_id: str | None) -> str | None: + if registry_record: + reg_id = registry_record.get("codex_session_id") + if isinstance(reg_id, str) and reg_id: + return reg_id + if session_id: + return session_id + if log_path: + for source in (log_path.stem, log_path.name): + match = SESSION_ID_PATTERN.search(source) + if match: + return match.group(0) + return log_path.name + return None + +def _parse_n(argv: list[str]) -> int: + if len(argv) <= 1: + return 1 + if argv[1] in ("-h", "--help"): + print("Usage: cpend [N]", file=sys.stderr) + raise SystemExit(EXIT_OK) try: - with session_file.open("r", encoding="utf-8") as f: - data = json.load(f) - if "pending_state" in data: - del data["pending_state"] - with session_file.open("w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - except Exception: - pass + n = int(argv[1]) + except ValueError: + print("Usage: cpend [N]", file=sys.stderr) + raise SystemExit(EXIT_ERROR) + return max(1, n) -def main() -> int: +def main(argv: list[str]) -> int: try: - comm = CodexCommunicator() - - pending = _load_pending_state() - if pending and pending.get("log_path"): - state = { - "log_path": Path(pending["log_path"]), - "offset": pending.get("offset", 0), - } - message, _ = comm.log_reader.try_get_message(state) - if message: - _clear_pending_state() - print(message) - return 0 - - output = comm.consume_pending(display=False) - if output: - print(output) - else: - print('暂无 Codex 回复') - return 0 + n = _parse_n(argv) + + registry_log_path, registry_record = _load_registry_log_path() + session_log_path, session_id = _load_session_log_path() + + log_path = registry_log_path or session_log_path + if not registry_log_path and log_path: + _debug(f"Using codex_session_path from .codex-session: {log_path}") + + session_filter = _derive_session_filter(log_path, registry_record, session_id) + reader = CodexLogReader(log_path=log_path, session_id_filter=session_filter) + + if n > 1: + conversations = reader.latest_conversations(n) + if not conversations: + print(t("no_reply_available", provider="Codex"), file=sys.stderr) + return EXIT_NO_REPLY + for i, (question, reply) in enumerate(conversations): + if question: + print(f"Q: {question}") + print(f"A: {reply}") + if i < len(conversations) - 1: + print("---") + return EXIT_OK + + message = reader.latest_message() + if not message: + print(t("no_reply_available", provider="Codex"), file=sys.stderr) + return EXIT_NO_REPLY + print(message) + return EXIT_OK except Exception as exc: - print(f"❌ 执行失败: {exc}") - return 1 + if _debug_enabled(): + import traceback + + traceback.print_exc() + print(f"[ERROR] {t('execution_failed', error=exc)}", file=sys.stderr) + return EXIT_ERROR if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv)) diff --git a/bin/cping b/bin/cping index 730ca0e..702478f 100755 --- a/bin/cping +++ b/bin/cping @@ -1,17 +1,18 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -cping 命令入口点 -测试与 Codex 的连通性 +cping command entry point +Test connectivity with Codex """ import sys import os from pathlib import Path -# 添加lib目录到Python路径 script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() try: from codex_comm import CodexCommunicator @@ -24,13 +25,13 @@ try: return 0 if healthy else 1 except Exception as e: - print(f"❌ 连通性测试失败: {e}") + print(f"[ERROR] Connectivity test failed: {e}") return 1 if __name__ == "__main__": sys.exit(main()) except ImportError as e: - print(f"❌ 导入模块失败: {e}") - print("请确保 codex_comm.py 在同一目录下") + print(f"[ERROR] Module import failed: {e}") + print("Please ensure codex_comm.py is in the same directory") sys.exit(1) diff --git a/bin/gask b/bin/gask index c194292..cbb847b 100755 --- a/bin/gask +++ b/bin/gask @@ -1,37 +1,195 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -gask - 异步发送消息到 Gemini +gask - Send message to Gemini and wait for reply (sync). + +Designed to be used with Claude Code's run_in_background=true. +If --output is provided, reply is written atomically to that file and stdout stays empty. """ from __future__ import annotations +import os +import subprocess import sys from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + +from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + +import time + +from env_utils import env_bool +from askd_client import ( + autostart_enabled, + state_file_from_env, + find_project_session_file, + try_daemon_request, + maybe_start_daemon, + wait_for_daemon_ready, + check_background_mode, +) +from providers import GASK_CLIENT_SPEC + +def _wait_for_done_reply(log_reader, state: dict, timeout: float, req_id: str, quiet: bool): + deadline = time.time() + timeout + latest = "" -from gemini_comm import GeminiCommunicator + from gaskd_protocol import extract_reply_for_req, is_done_text + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + reply, state = log_reader.wait_for_message(state, min(remaining, 1.0)) + if reply is None: + continue + latest = str(reply) + if is_done_text(latest, req_id): + return extract_reply_for_req(latest, req_id), EXIT_OK + + if latest and not quiet: + print("[WARN] Done marker not detected, returning partial reply", file=sys.stderr) + return extract_reply_for_req(latest, req_id), EXIT_NO_REPLY + + +def _usage() -> None: + print("Usage: gask [--timeout SECONDS] [--output FILE] ", file=sys.stderr) def main(argv: list[str]) -> int: - if len(argv) <= 1: - print("用法: gask <消息>", file=sys.stderr) - return 1 + if len(argv) <= 1 and sys.stdin.isatty(): + print("Usage: gask [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_ERROR + + output_path: Path | None = None + timeout: float | None = None + quiet = False + + parts: list[str] = [] + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + print("Usage: gask [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_OK + if token in ("-q", "--quiet"): + quiet = True + continue + if token in ("-o", "--output"): + try: + output_path = Path(next(it)).expanduser() + except StopIteration: + print("[ERROR] --output requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + print("[ERROR] --timeout requires a number", file=sys.stderr) + return EXIT_ERROR + except ValueError: + print("[ERROR] --timeout must be a number", file=sys.stderr) + return EXIT_ERROR + continue + parts.append(token) - message = " ".join(argv[1:]).strip() + message = " ".join(parts).strip() + if not message and not sys.stdin.isatty(): + message = sys.stdin.read().strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) - return 1 + print("[ERROR] Message cannot be empty", file=sys.stderr) + return EXIT_ERROR + + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 try: - comm = GeminiCommunicator() - comm.ask_async(message) - return 0 + from gemini_comm import GeminiCommunicator + from gaskd_protocol import make_req_id, wrap_gemini_prompt + + # Strict mode: require run_in_background=true in Claude Code + if os.environ.get("CLAUDECODE") == "1" and not check_background_mode(): + print("[ERROR] gask MUST be called with run_in_background=true", file=sys.stderr) + print("Correct usage: Bash(gask \"...\", run_in_background=true)", file=sys.stderr) + return EXIT_ERROR + + # Prefer daemon mode: daemon performs per-session serialization and should accept concurrent submissions. + daemon_result = try_daemon_request(GASK_CLIENT_SPEC, Path.cwd(), message, timeout, quiet, state_file_from_env(GASK_CLIENT_SPEC.state_file_env)) + if daemon_result is None and maybe_start_daemon(GASK_CLIENT_SPEC, Path.cwd()): + wait_for_daemon_ready(GASK_CLIENT_SPEC, timeout_s=min(2.0, max(0.2, float(timeout)))) + daemon_result = try_daemon_request(GASK_CLIENT_SPEC, Path.cwd(), message, timeout, quiet, state_file_from_env(GASK_CLIENT_SPEC.state_file_env)) + if daemon_result is not None: + reply, exit_code = daemon_result + if output_path: + atomic_write_text(output_path, reply + "\n") + return exit_code + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + + comm = GeminiCommunicator(lazy_init=True) + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + raise RuntimeError(f"[ERROR] Session error: {status}") + + marker = str(comm.session_info.get("pane_title_marker") or "").strip() + if marker: + session_key = f"gemini_marker:{marker}" + elif comm.pane_id: + session_key = f"gemini_pane:{comm.pane_id}" + else: + sid = str(comm.session_info.get("gemini_session_id") or "").strip() + if sid: + session_key = f"gemini:{sid}" + else: + session_key = f"gemini_file:{comm.project_session_file or os.getcwd()}" + + lock = ProviderLock("gemini", cwd=f"session:{session_key}", timeout=min(300.0, max(1.0, float(timeout)))) + if not lock.acquire(): + print("[ERROR] Another gask request is in progress (lock timeout).", file=sys.stderr) + return EXIT_ERROR + + try: + # Clear output file if specified (avoid stale content) + if output_path and output_path.exists(): + output_path.write_text("") + + # Reset log state to ignore any messages before lock acquisition + state = comm.log_reader.capture_state() + + req_id = make_req_id() + comm._send_via_terminal(wrap_gemini_prompt(message, req_id)) + reply, exit_code = _wait_for_done_reply(comm.log_reader, state, timeout, req_id, quiet) + if reply is None: + if not quiet: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return exit_code + + if output_path: + atomic_write_text(output_path, reply + "\n") + return exit_code + finally: + lock.release() + + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + except KeyboardInterrupt: + return 130 except Exception as exc: - print(f"❌ {exc}", file=sys.stderr) - return 1 + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_ERROR if __name__ == "__main__": diff --git a/bin/gask-w b/bin/gask-w index 518f1be..8690547 100755 --- a/bin/gask-w +++ b/bin/gask-w @@ -1,39 +1,103 @@ -#!/usr/bin/env python3 -""" -gask-w - 同步发送消息到 Gemini 并等待回复 +#!/usr/bin/env python """ +gask-w - Send message to Gemini and wait for reply (foreground sync). +stdout: reply text only (for piping). +stderr: progress and errors. +""" from __future__ import annotations - +import os import sys from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) - -from gemini_comm import GeminiCommunicator +from compat import setup_windows_encoding +setup_windows_encoding() def main(argv: list[str]) -> int: + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + from gemini_comm import GeminiCommunicator + from i18n import t + if len(argv) <= 1: - print("用法: gask-w <消息>", file=sys.stderr) - return 1 + print("Usage: gask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_ERROR + + output_path: Path | None = None + timeout: float | None = None + + parts: list[str] = [] + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + print("Usage: gask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_OK + if token in ("-o", "--output"): + try: + output_path = Path(next(it)).expanduser() + except StopIteration: + print("[ERROR] --output requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + print("[ERROR] --timeout requires a number", file=sys.stderr) + return EXIT_ERROR + except ValueError: + print("[ERROR] --timeout must be a number", file=sys.stderr) + return EXIT_ERROR + continue + parts.append(token) - message = " ".join(argv[1:]).strip() + message = " ".join(parts).strip() if not message: - print("❌ 消息内容不能为空", file=sys.stderr) - return 1 + print("[ERROR] Message cannot be empty", file=sys.stderr) + return EXIT_ERROR + + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 try: - comm = GeminiCommunicator() - reply = comm.ask_sync(message, timeout=0) - return 0 if reply else 1 + comm = GeminiCommunicator(lazy_init=True) + + # Check session health + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + print(f"[ERROR] Session error: {status}", file=sys.stderr) + return EXIT_ERROR + + # Send message + print(f"[INFO] {t('sending_to', provider='Gemini')}", file=sys.stderr, flush=True) + _, state = comm._send_message(message) + message_reply, _ = comm.log_reader.wait_for_message(state, timeout) + + if not message_reply: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return EXIT_NO_REPLY + + if output_path: + atomic_write_text(output_path, message_reply + "\n") + + sys.stdout.write(message_reply) + if not message_reply.endswith("\n"): + sys.stdout.write("\n") + return EXIT_OK + except KeyboardInterrupt: + print("[ERROR] Interrupted", file=sys.stderr) + return 130 except Exception as exc: - print(f"❌ {exc}", file=sys.stderr) - return 1 + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_ERROR if __name__ == "__main__": - sys.exit(main(sys.argv)) + raise SystemExit(main(sys.argv)) diff --git a/bin/gaskd b/bin/gaskd new file mode 100644 index 0000000..6db1c05 --- /dev/null +++ b/bin/gaskd @@ -0,0 +1,54 @@ +#!/usr/bin/env python +""" +gaskd - Gemini ask daemon. + +Implements Scheme-2: per-session serialized sending + session-file-driven reply extraction with req_id. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) + +from compat import setup_windows_encoding + +setup_windows_encoding() + +from gaskd_daemon import GaskdServer, shutdown_daemon + + +def _parse_listen(value: str) -> tuple[str, int]: + value = (value or "").strip() + if not value: + return "127.0.0.1", 0 + if ":" not in value: + return value, 0 + host, port_s = value.rsplit(":", 1) + return host or "127.0.0.1", int(port_s or "0") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="gask daemon (Gemini)") + ap.add_argument("--listen", default=os.environ.get("CCB_GASKD_LISTEN", "127.0.0.1:0"), help="host:port (default 127.0.0.1:0)") + ap.add_argument("--state-file", default=os.environ.get("CCB_GASKD_STATE_FILE", ""), help="Override state file path") + ap.add_argument("--shutdown", action="store_true", help="Shutdown running daemon") + args = ap.parse_args(argv[1:]) + + state_file = Path(args.state_file).expanduser() if args.state_file else None + + if args.shutdown: + ok = shutdown_daemon(state_file=state_file) + return 0 if ok else 1 + + host, port = _parse_listen(args.listen) + server = GaskdServer(host=host, port=port, state_file=state_file) + return server.serve_forever() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/gpend b/bin/gpend index 7c463d3..84bd674 100755 --- a/bin/gpend +++ b/bin/gpend @@ -1,35 +1,139 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -gpend - 查看 Gemini 最新回复 +gpend - View latest Gemini reply """ +import json +import os import sys from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() + +from i18n import t try: - from gemini_comm import GeminiCommunicator + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK + from gemini_comm import GeminiLogReader except ImportError as exc: - print(f"导入失败: {exc}") + print(f"Import failed: {exc}") sys.exit(1) -def main() -> int: +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("GPEND_DEBUG") in ("1", "true", "yes")) + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + +def _load_session_path() -> Path | None: + """Load gemini_session_path from .gemini-session if exists""" + session_file = Path.cwd() / ".gemini-session" + if not session_file.exists(): + return None + try: + with session_file.open("r", encoding="utf-8-sig") as f: + data = json.load(f) + path_str = data.get("gemini_session_path") + if path_str: + return Path(path_str).expanduser() + except Exception as exc: + _debug(f"Failed to read .gemini-session ({session_file}): {exc}") + return None + + +def _update_session_file(actual_session: Path) -> None: + """Update .gemini-session with actual session path if different""" + session_file = Path.cwd() / ".gemini-session" + if not session_file.exists(): + return try: - comm = GeminiCommunicator() - output = comm.consume_pending(display=False) - if output: - print(output) - else: - print('暂无 Gemini 回复') - return 0 + with session_file.open("r", encoding="utf-8") as f: + data = json.load(f) + + actual_str = str(actual_session) + if data.get("gemini_session_path") == actual_str: + return + + data["gemini_session_path"] = actual_str + try: + data["gemini_project_hash"] = actual_session.parent.parent.name + except Exception: + pass + + with session_file.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + _debug(f"Updated .gemini-session with: {actual_str}") except Exception as exc: - print(f"❌ 执行失败: {exc}") + _debug(f"Failed to update .gemini-session: {exc}") + + +def _parse_n(argv: list[str]) -> int: + if len(argv) <= 1: return 1 + if argv[1] in ("-h", "--help"): + print("Usage: gpend [N]", file=sys.stderr) + raise SystemExit(EXIT_OK) + try: + n = int(argv[1]) + except ValueError: + print("Usage: gpend [N]", file=sys.stderr) + raise SystemExit(EXIT_ERROR) + return max(1, n) + + +def main(argv: list[str]) -> int: + try: + n = _parse_n(argv) + + # Try session-specific path first + session_path = _load_session_path() + if session_path: + _debug(f"Using gemini_session_path from .gemini-session: {session_path}") + + reader = GeminiLogReader() + if session_path and session_path.exists(): + reader.set_preferred_session(session_path) + + # Auto-update .gemini-session with actual session path + actual_session = reader.current_session_path() + if actual_session: + _update_session_file(actual_session) + + if n > 1: + conversations = reader.latest_conversations(n) + if not conversations: + print(t("no_reply_available", provider="Gemini"), file=sys.stderr) + return EXIT_NO_REPLY + for i, (question, reply) in enumerate(conversations): + if question: + print(f"Q: {question}") + print(f"A: {reply}") + if i < len(conversations) - 1: + print("---") + return EXIT_OK + + message = reader.latest_message() + if not message: + print(t("no_reply_available", provider="Gemini"), file=sys.stderr) + return EXIT_NO_REPLY + print(message) + return EXIT_OK + except Exception as exc: + if _debug_enabled(): + import traceback + traceback.print_exc() + print(f"[ERROR] {t('execution_failed', error=exc)}", file=sys.stderr) + return EXIT_ERROR if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv)) diff --git a/bin/gping b/bin/gping index cff918f..d49c65f 100755 --- a/bin/gping +++ b/bin/gping @@ -1,6 +1,6 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -gping - 测试 Gemini 连通性 +gping - Test Gemini connectivity """ import sys @@ -9,6 +9,8 @@ from pathlib import Path script_dir = Path(__file__).resolve().parent lib_dir = script_dir.parent / "lib" sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding +setup_windows_encoding() try: from gemini_comm import GeminiCommunicator @@ -20,12 +22,12 @@ try: print(message) return 0 if healthy else 1 except Exception as e: - print(f"❌ Gemini 连通性测试失败: {e}") + print(f"[ERROR] Gemini connectivity test failed: {e}") return 1 if __name__ == "__main__": sys.exit(main()) except ImportError as e: - print(f"❌ 导入模块失败: {e}") + print(f"[ERROR] Module import failed: {e}") sys.exit(1) diff --git a/bin/oask b/bin/oask new file mode 100755 index 0000000..fac98c2 --- /dev/null +++ b/bin/oask @@ -0,0 +1,304 @@ +#!/usr/bin/env python +""" +oask - Send message to OpenCode and wait for reply (sync). + +Designed to be used with Claude Code's run_in_background=true. +If --output is provided, the reply is written atomically to that file and stdout stays empty. +""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path +from typing import Optional, Tuple + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() +from env_utils import env_bool +from process_lock import ProviderLock +from askd_client import ( + autostart_enabled, + state_file_from_env, + find_project_session_file, + try_daemon_request, + maybe_start_daemon, + wait_for_daemon_ready, + check_background_mode, +) +from providers import OASK_CLIENT_SPEC + + +def _daemon_request_with_retries(work_dir: Path, message: str, timeout: float, quiet: bool) -> Optional[tuple[str, int]]: + """ + Best-effort daemon request with retries to avoid autostart races. + + Without this, a burst of concurrent `oask` invocations can fall back to direct mode + before the daemon is fully ready, causing only some requests to be submitted. + """ + state_file = state_file_from_env(OASK_CLIENT_SPEC.state_file_env) + + # Fast path + result = try_daemon_request(OASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file) + if result is not None: + return result + + # If daemon mode is disabled or no session, don't spin. + if not env_bool(OASK_CLIENT_SPEC.enabled_env, True): + return None + if not find_project_session_file(work_dir, OASK_CLIENT_SPEC.session_filename): + return None + + # Stale state files can block daemon mode (e.g. daemon exited/crashed but the json remains). + if state_file and state_file.exists(): + try: + from oaskd_daemon import ping_daemon + + if not ping_daemon(timeout_s=0.2, state_file=state_file): + try: + state_file.unlink() + except Exception: + pass + except Exception: + pass + + started = maybe_start_daemon(OASK_CLIENT_SPEC, work_dir) + if started: + wait_for_daemon_ready(OASK_CLIENT_SPEC, _daemon_startup_wait_s(timeout), state_file) + + # Retry briefly; re-reading state helps when multiple daemons were attempted. + wait_s = _daemon_retry_wait_s(timeout) + deadline = time.time() + min(3.0, max(0.2, float(timeout))) + while time.time() < deadline: + result = try_daemon_request(OASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file) + if result is not None: + return result + time.sleep(wait_s) + + return None + + +def _daemon_startup_wait_s(timeout: float) -> float: + raw = (os.environ.get("CCB_OASKD_STARTUP_WAIT_S") or "").strip() + if raw: + try: + v = float(raw) + except Exception: + v = 0.0 + if v > 0: + return min(max(0.2, v), max(0.2, float(timeout))) + # Default: allow a bit more time for concurrent first-start races. + return min(8.0, max(1.0, float(timeout))) + + +def _daemon_retry_wait_s(timeout: float) -> float: + raw = (os.environ.get("CCB_OASKD_RETRY_WAIT_S") or "").strip() + if raw: + try: + v = float(raw) + except Exception: + v = 0.0 + if v > 0: + return min(1.0, max(0.05, v)) + return min(0.3, max(0.05, float(timeout) / 50.0)) + + +def _wait_for_done_reply(log_reader, state: dict, timeout: float, req_id: str, quiet: bool): + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK + from oaskd_protocol import is_done_text, strip_done_text + + deadline = time.time() + timeout + chunks: list[str] = [] + cancel_enabled = env_bool("CCB_OASKD_CANCEL_DETECT", False) + session_id = state.get("session_id") if cancel_enabled and isinstance(state.get("session_id"), str) else None + cancel_cursor = log_reader.open_cancel_log_cursor() if cancel_enabled and session_id else None + cancel_since_s = time.time() if cancel_enabled else 0.0 + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + reply, state = log_reader.wait_for_message(state, min(remaining, 1.0)) + + # Fallback cancellation detection via OpenCode server logs. + if cancel_enabled and session_id and cancel_cursor is not None: + try: + cancelled_log, cancel_cursor = log_reader.detect_cancel_event_in_logs( + cancel_cursor, session_id=session_id, since_epoch_s=cancel_since_s + ) + if cancelled_log: + return "❌ OpenCode request cancelled.", EXIT_ERROR + except Exception: + pass + + # OpenCode cancellation writes an assistant message with MessageAbortedError (no text parts). + # + # Important: do NOT advance the caller's state baseline when not cancelled (see oaskd_daemon). + if cancel_enabled: + try: + cancelled, _new_state = log_reader.detect_cancelled_since(state, req_id=req_id) + if cancelled: + return "❌ OpenCode request cancelled.", EXIT_ERROR + except Exception: + pass + + if reply is None: + continue + chunks.append(reply) + combined = "\n".join(chunks) + if is_done_text(combined, req_id): + return strip_done_text(combined, req_id), EXIT_OK + + combined = "\n".join(chunks) + if combined and not quiet: + print("[WARN] Done marker not detected, returning partial reply", file=sys.stderr) + return strip_done_text(combined, req_id), EXIT_NO_REPLY + + +def _usage() -> None: + print("Usage: oask [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + + +def _parse_args(argv: list[str]) -> Tuple[Optional[Path], float, str, bool]: + output: Optional[Path] = None + timeout: Optional[float] = None + quiet = False + parts: list[str] = [] + + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + _usage() + raise SystemExit(0) + if token in ("-q", "--quiet"): + quiet = True + continue + if token in ("-o", "--output"): + try: + output = Path(next(it)).expanduser() + except StopIteration: + raise ValueError("--output requires a file path") + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + raise ValueError("--timeout requires a number") + except ValueError as exc: + raise ValueError(f"Invalid --timeout: {exc}") + continue + parts.append(token) + + message = " ".join(parts).strip() + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 + return output, timeout, message, quiet + + +def main(argv: list[str]) -> int: + try: + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + from opencode_comm import OpenCodeCommunicator + from opencode_comm import OpenCodeLogReader + from oaskd_protocol import make_req_id, wrap_opencode_prompt + + output_path, timeout, message, quiet = _parse_args(argv) + if not message and not sys.stdin.isatty(): + message = sys.stdin.read().strip() + if not message: + _usage() + return EXIT_ERROR + + if os.environ.get("CLAUDECODE") == "1" and not check_background_mode(): + print("[ERROR] oask MUST be called with run_in_background=true", file=sys.stderr) + print("Correct usage: Bash(oask \"...\", run_in_background=true)", file=sys.stderr) + return EXIT_ERROR + + # Prefer daemon mode: daemon performs per-session serialization and should accept concurrent submissions. + daemon_result = _daemon_request_with_retries(Path.cwd(), message, timeout, quiet) + if daemon_result is not None: + reply, exit_code = daemon_result + if output_path: + atomic_write_text(output_path, reply + "\n") + return exit_code + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + + comm = OpenCodeCommunicator(lazy_init=True) + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + raise RuntimeError(f"[ERROR] Session error: {status}") + + # Prefer the canonical session_key computation used by the daemon to avoid cross-mode + # serialization mismatches (e.g. if one caller sees marker while another doesn't). + try: + from oaskd_session import compute_session_key, load_project_session + + session = load_project_session(Path.cwd()) + except Exception: + session = None + + if session: + session_key = compute_session_key(session) + else: + marker = str(comm.session_info.get("pane_title_marker") or "").strip() + if marker: + session_key = f"opencode_marker:{marker}" + elif comm.pane_id: + session_key = f"opencode_pane:{comm.pane_id}" + else: + sid = str(comm.session_info.get("session_id") or comm.session_id or "").strip() + if sid: + session_key = f"opencode:{sid}" + else: + session_key = f"opencode_file:{comm.project_session_file or os.getcwd()}" + + lock = ProviderLock("opencode", cwd=f"session:{session_key}", timeout=min(300.0, max(1.0, float(timeout)))) + if not lock.acquire(): + print("[ERROR] Another oask request is in progress (lock timeout).", file=sys.stderr) + return EXIT_ERROR + + try: + # Clear output file if specified (avoid stale content) + if output_path and output_path.exists(): + output_path.write_text("") + + req_id = make_req_id() + log_reader = OpenCodeLogReader(work_dir=Path.cwd(), session_id_filter=(comm.session_id or None)) + state = log_reader.capture_state() + comm._send_via_terminal(wrap_opencode_prompt(message, req_id)) + + reply, exit_code = _wait_for_done_reply(log_reader, state, timeout, req_id, quiet) + if reply is None: + if not quiet: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return exit_code + + if output_path: + atomic_write_text(output_path, reply + "\n") + return exit_code + finally: + lock.release() + + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return exit_code + except KeyboardInterrupt: + return 130 + except Exception as exc: + print(exc, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/oask-w b/bin/oask-w new file mode 100755 index 0000000..6316adb --- /dev/null +++ b/bin/oask-w @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +oask-w - Send message to OpenCode and wait for reply (foreground sync). + +stdout: reply text only (for piping). +stderr: progress and errors. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + + +def main(argv: list[str]) -> int: + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text + from i18n import t + from opencode_comm import OpenCodeCommunicator + + if len(argv) <= 1: + print("Usage: oask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_ERROR + + output_path: Path | None = None + timeout: float | None = None + + parts: list[str] = [] + it = iter(argv[1:]) + for token in it: + if token in ("-h", "--help"): + print("Usage: oask-w [--timeout SECONDS] [--output FILE] ", file=sys.stderr) + return EXIT_OK + if token in ("-o", "--output"): + try: + output_path = Path(next(it)).expanduser() + except StopIteration: + print("[ERROR] --output requires a file path", file=sys.stderr) + return EXIT_ERROR + continue + if token in ("-t", "--timeout"): + try: + timeout = float(next(it)) + except StopIteration: + print("[ERROR] --timeout requires a number", file=sys.stderr) + return EXIT_ERROR + except ValueError: + print("[ERROR] --timeout must be a number", file=sys.stderr) + return EXIT_ERROR + continue + parts.append(token) + + message = " ".join(parts).strip() + if not message: + print("[ERROR] Message cannot be empty", file=sys.stderr) + return EXIT_ERROR + + if timeout is None: + try: + timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0")) + except Exception: + timeout = 3600.0 + + try: + comm = OpenCodeCommunicator(lazy_init=True) + healthy, status = comm._check_session_health_impl(probe_terminal=False) + if not healthy: + print(f"[ERROR] Session error: {status}", file=sys.stderr) + return EXIT_ERROR + + print(f"[INFO] {t('sending_to', provider='OpenCode')}", file=sys.stderr, flush=True) + _, state = comm._send_message(message) + reply, _ = comm.log_reader.wait_for_message(state, timeout) + if not reply: + print(f"[TIMEOUT] Timeout after {int(timeout)}s", file=sys.stderr) + return EXIT_NO_REPLY + + if output_path: + atomic_write_text(output_path, reply + "\n") + + sys.stdout.write(reply) + if not reply.endswith("\n"): + sys.stdout.write("\n") + return EXIT_OK + except KeyboardInterrupt: + print("[ERROR] Interrupted", file=sys.stderr) + return 130 + except Exception as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return EXIT_ERROR + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/oaskd b/bin/oaskd new file mode 100644 index 0000000..3ae80c2 --- /dev/null +++ b/bin/oaskd @@ -0,0 +1,54 @@ +#!/usr/bin/env python +""" +oaskd - OpenCode ask daemon. + +Implements Scheme-2: per-session serialized sending + storage-driven reply extraction with req_id. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) + +from compat import setup_windows_encoding + +setup_windows_encoding() + +from oaskd_daemon import OaskdServer, shutdown_daemon + + +def _parse_listen(value: str) -> tuple[str, int]: + value = (value or "").strip() + if not value: + return "127.0.0.1", 0 + if ":" not in value: + return value, 0 + host, port_s = value.rsplit(":", 1) + return host or "127.0.0.1", int(port_s or "0") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="oask daemon (OpenCode)") + ap.add_argument("--listen", default=os.environ.get("CCB_OASKD_LISTEN", "127.0.0.1:0"), help="host:port (default 127.0.0.1:0)") + ap.add_argument("--state-file", default=os.environ.get("CCB_OASKD_STATE_FILE", ""), help="Override state file path") + ap.add_argument("--shutdown", action="store_true", help="Shutdown running daemon") + args = ap.parse_args(argv[1:]) + + state_file = Path(args.state_file).expanduser() if args.state_file else None + + if args.shutdown: + ok = shutdown_daemon(state_file=state_file) + return 0 if ok else 1 + + host, port = _parse_listen(args.listen) + server = OaskdServer(host=host, port=port, state_file=state_file) + return server.serve_forever() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/bin/opend b/bin/opend new file mode 100755 index 0000000..f8c7e6e --- /dev/null +++ b/bin/opend @@ -0,0 +1,76 @@ +#!/usr/bin/env python +""" +opend - View latest OpenCode reply +""" + +import json +import os +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + +from i18n import t + + +def _load_project_id() -> str: + """Load project_id from .opencode-session or compute from cwd""" + session_file = Path.cwd() / '.opencode-session' + work_dir = Path.cwd() + + if session_file.exists(): + try: + with session_file.open('r', encoding='utf-8-sig') as f: + data = json.load(f) + wd = data.get('work_dir') + if wd: + work_dir = Path(wd) + except Exception: + pass + + from opencode_comm import compute_opencode_project_id + return compute_opencode_project_id(work_dir) + + +try: + from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK + from opencode_comm import OpenCodeLogReader +except ImportError as exc: + print(f"Import failed: {exc}", file=sys.stderr) + sys.exit(1) + + +def _debug_enabled() -> bool: + return (os.environ.get("CCB_DEBUG") in ("1", "true", "yes")) or (os.environ.get("OPEND_DEBUG") in ("1", "true", "yes")) + + +def main(argv: list[str]) -> int: + try: + if len(argv) > 1 and argv[1] in ("-h", "--help"): + print("Usage: opend", file=sys.stderr) + return EXIT_OK + + project_id = _load_project_id() + reader = OpenCodeLogReader(project_id=project_id) + message = reader.latest_message() + if not message: + print(t("no_reply_available", provider="OpenCode"), file=sys.stderr) + return EXIT_NO_REPLY + print(message) + return EXIT_OK + except Exception as exc: + if _debug_enabled(): + import traceback + + traceback.print_exc() + print(f"[ERROR] {t('execution_failed', error=exc)}", file=sys.stderr) + return EXIT_ERROR + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/bin/oping b/bin/oping new file mode 100755 index 0000000..30c8712 --- /dev/null +++ b/bin/oping @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +oping - Test OpenCode connectivity +""" + +import sys +from pathlib import Path + +script_dir = Path(__file__).resolve().parent +lib_dir = script_dir.parent / "lib" +sys.path.insert(0, str(lib_dir)) +from compat import setup_windows_encoding + +setup_windows_encoding() + +try: + from opencode_comm import OpenCodeCommunicator + + def main() -> int: + try: + comm = OpenCodeCommunicator() + healthy, message = comm.ping(display=False) + print(message) + return 0 if healthy else 1 + except Exception as exc: + print(f"[ERROR] OpenCode connectivity test failed: {exc}") + return 1 + + if __name__ == "__main__": + sys.exit(main()) + +except ImportError as exc: + print(f"[ERROR] Module import failed: {exc}") + sys.exit(1) diff --git a/ccb b/ccb index a62e8c4..ec1b024 100755 --- a/ccb +++ b/ccb @@ -1,8 +1,8 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """ -ccb (Claude Code Bridge) - 统一 AI 启动器 -支持 Claude + Codex / Claude + Gemini / 三者同时 -支持 tmux、WezTerm 和 iTerm2 终端 +ccb (Claude Code Bridge) - Unified AI Launcher +Supports Claude + Codex / Claude + Gemini / all three simultaneously +Supports tmux, WezTerm and iTerm2 terminals """ import sys @@ -19,20 +19,156 @@ import platform import tempfile import re import shutil +import posixpath from pathlib import Path script_dir = Path(__file__).resolve().parent sys.path.insert(0, str(script_dir / "lib")) from terminal import TmuxBackend, WeztermBackend, Iterm2Backend, detect_terminal, is_wsl, get_shell_type +from compat import setup_windows_encoding +from ccb_config import get_backend_env +from session_utils import safe_write_session, check_session_writable +from pane_registry import upsert_registry +from i18n import t -VERSION = "2.1" +setup_windows_encoding() + +backend_env = get_backend_env() +if backend_env and not os.environ.get("CCB_BACKEND_ENV"): + os.environ["CCB_BACKEND_ENV"] = backend_env + +VERSION = "3.0.0" +GIT_COMMIT = "" +GIT_DATE = "" + +_WIN_DRIVE_RE = re.compile(r"^[A-Za-z]:([/\\\\]|$)") +_MNT_DRIVE_RE = re.compile(r"^/mnt/([A-Za-z])/(.*)$") +_MSYS_DRIVE_RE = re.compile(r"^/([A-Za-z])/(.*)$") + + +def _looks_like_windows_path(value: str) -> bool: + s = value.strip() + if not s: + return False + if _WIN_DRIVE_RE.match(s): + return True + if s.startswith("\\\\") or s.startswith("//"): + return True + return False + + +def _normalize_path_for_match(value: str) -> str: + """ + Normalize a path-like string for loose matching across Windows/WSL/MSYS variations. + This is used only for selecting a session for *current* cwd, so favor robustness. + """ + s = (value or "").strip() + if not s: + return "" + + # Expand "~" early (common in shell-originated values). If expansion fails, keep original. + if s.startswith("~"): + try: + s = os.path.expanduser(s) + except Exception: + pass + + # If the path is relative, absolutize it against current cwd for matching purposes only. + # This reduces false negatives when upstream tools record a relative cwd. + # NOTE: treat Windows-like absolute paths as absolute even on non-Windows hosts. + try: + preview = s.replace("\\", "/") + is_abs = ( + preview.startswith("/") + or preview.startswith("//") + or bool(_WIN_DRIVE_RE.match(preview)) + or preview.startswith("\\\\") + ) + if not is_abs: + s = str((Path.cwd() / Path(s)).absolute()) + except Exception: + pass + + s = s.replace("\\", "/") + + # Map WSL drive mount to Windows-style drive path for comparison. + m = _MNT_DRIVE_RE.match(s) + if m: + drive = m.group(1).lower() + rest = m.group(2) + s = f"{drive}:/{rest}" + else: + # Map MSYS /c/... to c:/... (Git-Bash/MSYS2 environments on Windows). + m = _MSYS_DRIVE_RE.match(s) + if m and ("MSYSTEM" in os.environ or os.name == "nt"): + drive = m.group(1).lower() + rest = m.group(2) + s = f"{drive}:/{rest}" + + # Collapse redundant separators and dot segments using POSIX semantics (we forced "/"). + # Preserve UNC double-slash prefix. + if s.startswith("//"): + prefix = "//" + rest = s[2:] + rest = posixpath.normpath(rest) + s = prefix + rest.lstrip("/") + else: + s = posixpath.normpath(s) + + # Normalize Windows drive letter casing (c:/..., not C:/...). + if _WIN_DRIVE_RE.match(s): + s = s[0].lower() + s[1:] + + # Drop trailing slash (but keep "/" and "c:/"). + if len(s) > 1 and s.endswith("/"): + s = s.rstrip("/") + if _WIN_DRIVE_RE.match(s) and not s.endswith("/"): + # Ensure drive root keeps trailing slash form "c:/". + if len(s) == 2: + s = s + "/" + + # On Windows-like paths, compare case-insensitively to avoid drive letter/case issues. + if _looks_like_windows_path(s): + s = s.casefold() + + return s + + +def _work_dir_match_keys(work_dir: Path) -> set[str]: + keys: set[str] = set() + candidates: list[str] = [] + for raw in (os.environ.get("PWD"), str(work_dir)): + if raw: + candidates.append(raw) + try: + candidates.append(str(work_dir.resolve())) + except Exception: + pass + for candidate in candidates: + normalized = _normalize_path_for_match(candidate) + if normalized: + keys.add(normalized) + return keys + + +def _extract_session_work_dir_norm(session_data: dict) -> str: + """Extract a normalized work dir marker from a session file payload.""" + if not isinstance(session_data, dict): + return "" + raw_norm = session_data.get("work_dir_norm") + if isinstance(raw_norm, str) and raw_norm.strip(): + return _normalize_path_for_match(raw_norm) + raw = session_data.get("work_dir") + if isinstance(raw, str) and raw.strip(): + return _normalize_path_for_match(raw) + return "" def _get_git_info() -> str: try: result = subprocess.run( ["git", "-C", str(script_dir), "log", "-1", "--format=%h %ci"], - capture_output=True, text=True, timeout=2 + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=2 ) if result.returncode == 0: return result.stdout.strip() @@ -59,8 +195,21 @@ def _build_keep_open_cmd(provider: str, start_cmd: str) -> str: ) +def _build_pane_title_cmd(marker: str) -> str: + if get_shell_type() == "powershell": + safe = marker.replace("'", "''") + return f"$Host.UI.RawUI.WindowTitle = '{safe}'; " + return f"printf '\\033]0;{marker}\\007'; " + + class AILauncher: - def __init__(self, providers: list, resume: bool = False, auto: bool = False, no_claude: bool = False): + def __init__( + self, + providers: list, + resume: bool = False, + auto: bool = False, + no_claude: bool = False, + ): self.providers = providers or ["codex"] self.resume = resume self.auto = auto @@ -77,69 +226,173 @@ class AILauncher: self.iterm2_panes = {} self.processes = {} + def _maybe_start_caskd(self) -> None: + def _bool_from_env(name: str): + raw = os.environ.get(name) + if raw is None or raw == "": + return None + v = raw.strip().lower() + if v in {"0", "false", "no", "off"}: + return False + if v in {"1", "true", "yes", "on"}: + return True + return None + + # Prefer the new name; keep CCB_AUTO_CASKD for backwards compatibility. + autostart = _bool_from_env("CCB_CASKD_AUTOSTART") + if autostart is None: + autostart = _bool_from_env("CCB_AUTO_CASKD") + if autostart is False: + return + if _bool_from_env("CCB_CASKD") is False: + return + if "codex" not in self.providers: + return + if self.terminal_type not in ("wezterm", "iterm2"): + return + try: + from caskd_daemon import ping_daemon, read_state + except Exception as exc: + print(f"⚠️ Failed to import caskd modules: {exc}") + return + + state_file = None + raw_state_file = (os.environ.get("CCB_CASKD_STATE_FILE") or "").strip() + if raw_state_file: + try: + state_file = Path(raw_state_file).expanduser() + except Exception: + state_file = None + + if ping_daemon(state_file=state_file): + st = read_state(state_file=state_file) or {} + host = st.get("host") + port = st.get("port") + if host and port: + print(f"✅ caskd already running at {host}:{port}") + else: + print("✅ caskd already running") + return + + caskd_script = self.script_dir / "bin" / "caskd" + if not caskd_script.exists(): + print("⚠️ caskd not found (bin/caskd). Reinstall or update your checkout.") + return + + 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 + + try: + subprocess.Popen([sys.executable, str(caskd_script)], **kwargs) + except Exception as exc: + print(f"⚠️ Failed to start caskd: {exc}") + return + + deadline = time.time() + 2.0 + while time.time() < deadline: + if ping_daemon(timeout_s=0.2, state_file=state_file): + st = read_state(state_file=state_file) or {} + host = st.get("host") + port = st.get("port") + if host and port: + print(f"✅ caskd started at {host}:{port}") + else: + print("✅ caskd started") + return + time.sleep(0.1) + print("⚠️ caskd start requested, but daemon not reachable yet") + def _detect_terminal_type(self): - # 环境变量强制指定 + # Forced by environment variable forced = (os.environ.get("CCB_TERMINAL") or os.environ.get("CODEX_TERMINAL") or "").strip().lower() if forced in {"wezterm", "tmux"}: return forced - # 在 WezTerm pane 内时,强制使用 wezterm,完全不依赖 tmux + # When inside WezTerm pane, force wezterm, no tmux dependency if os.environ.get("WEZTERM_PANE"): return "wezterm" - # 只有在 iTerm2 环境中才用 iTerm2 分屏 + # Only use iTerm2 split when in iTerm2 environment if os.environ.get("ITERM_SESSION_ID"): return "iterm2" - # 使用 detect_terminal() 自动检测(WezTerm 优先) + # Use detect_terminal() for auto-detection (WezTerm preferred) detected = detect_terminal() if detected: return detected - # 兜底:如果都没有,返回 None 让后续逻辑处理 + # Fallback: if nothing found, return None for later handling return None def _detect_launch_terminal(self): - """选择用于启动新窗口的终端程序(仅 tmux 模式下使用)""" - # WezTerm 模式不需要外部终端程序 + """Select terminal program for launching new windows (tmux mode only)""" + # WezTerm mode doesn't need external terminal program if self.terminal_type == "wezterm": return None - # tmux 模式下选择终端 + # tmux mode: select terminal terminals = ["gnome-terminal", "konsole", "alacritty", "xterm"] for term in terminals: if shutil.which(term): return term return "tmux" + def _launch_script_in_macos_terminal(self, script_file: Path) -> bool: + """macOS: Use Terminal.app to open new window for script (avoid tmux launcher nesting issues)""" + if platform.system() != "Darwin": + return False + if not shutil.which("osascript"): + return False + env = os.environ.copy() + env["CCB_WRAPPER_SCRIPT"] = str(script_file) + subprocess.Popen( + [ + "osascript", + "-e", + 'tell application "Terminal" to do script "/bin/bash " & quoted form of (system attribute "CCB_WRAPPER_SCRIPT")', + "-e", + 'tell application "Terminal" to activate', + ], + env=env, + ) + return True + def _start_provider(self, provider: str) -> bool: - # 处理未检测到终端的情况 + # Handle case when no terminal detected if self.terminal_type is None: - print("❌ 未检测到可用的终端后端(WezTerm 或 tmux)") - print(" 解决方案:") - print(" - 安装 WezTerm(推荐): https://wezfurlong.org/wezterm/") - print(" - 或安装 tmux") - print(" - 或设置环境变量 CCB_TERMINAL=wezterm 并配置 CODEX_WEZTERM_BIN") + print(f"❌ {t('no_terminal_backend')}") + print(f" {t('solutions')}") + print(f" - {t('install_wezterm')}") + print(f" - {t('or_install_tmux')}") + print(f" - {t('or_set_ccb_terminal')}") return False - # WezTerm 模式:完全不依赖 tmux + # WezTerm mode: no tmux dependency if self.terminal_type == "wezterm": - print(f"🚀 启动 {provider.capitalize()} 后端 (wezterm)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='wezterm')}") return self._start_provider_wezterm(provider) elif self.terminal_type == "iterm2": return self._start_provider_iterm2(provider) - # tmux 模式:检查 tmux 是否可用 + # tmux mode: check if tmux is available if not shutil.which("tmux"): - # 尝试 fallback 到 WezTerm + # Try fallback to WezTerm if detect_terminal() == "wezterm": self.terminal_type = "wezterm" - print(f"🚀 启动 {provider.capitalize()} 后端 (wezterm - tmux 不可用)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='wezterm - tmux unavailable')}") return self._start_provider_wezterm(provider) else: - print("❌ tmux 未安装,且 WezTerm 不可用") - print(" 解决方案: 安装 WezTerm(推荐)或 tmux") + print(f"❌ {t('tmux_not_installed')}") + print(f" {t('install_wezterm_or_tmux')}") return False - print(f"🚀 启动 {provider.capitalize()} 后端 (tmux)...") + print(f"🚀 {t('starting_backend', provider=provider.capitalize(), terminal='tmux')}") tmux_session = f"{provider}-{int(time.time()) % 100000}-{os.getpid()}" self.tmux_sessions[provider] = tmux_session @@ -148,8 +401,10 @@ class AILauncher: return self._start_codex(tmux_session) elif provider == "gemini": return self._start_gemini(tmux_session) + elif provider == "opencode": + return self._start_opencode(tmux_session) else: - print(f"❌ 未知的 provider: {provider}") + print(f"❌ {t('unknown_provider', provider=provider)}") return False def _start_provider_wezterm(self, provider: str) -> bool: @@ -160,29 +415,49 @@ class AILauncher: keep_open = os.environ.get("CODEX_WEZTERM_KEEP_OPEN", "1").lower() not in {"0", "false", "no", "off"} if keep_open: start_cmd = _build_keep_open_cmd(provider, start_cmd) - # Layout: first backend splits to the right of current pane, subsequent backends stack below - # the first backend pane to form a right-side column (top/bottom). - direction = "right" if not self.wezterm_panes else "bottom" - parent_pane = None - if direction == "bottom": - try: - parent_pane = next(iter(self.wezterm_panes.values())) - except StopIteration: - parent_pane = None + want_grid = {"codex", "gemini", "opencode"}.issubset(set(self.providers)) + current_pane = os.environ.get("WEZTERM_PANE") + if want_grid and current_pane: + # 2x2 grid with current pane as top-left: + # codex -> top-right, gemini -> bottom-left, opencode -> bottom-right. + if not self.wezterm_panes: + direction = "right" + parent_pane = current_pane + elif provider == "gemini": + direction = "bottom" + parent_pane = current_pane + else: + direction = "bottom" + parent_pane = self.wezterm_panes.get("codex") or next(iter(self.wezterm_panes.values())) + else: + # Legacy layout: right column stack (top/bottom). + direction = "right" if not self.wezterm_panes else "bottom" + parent_pane = None + if direction == "bottom": + try: + parent_pane = next(iter(self.wezterm_panes.values())) + except StopIteration: + parent_pane = None + pane_title_marker = f"CCB-{provider}-{self.session_id[:12]}" + title_cmd = _build_pane_title_cmd(pane_title_marker) + full_cmd = title_cmd + start_cmd backend = WeztermBackend() - pane_id = backend.create_pane(start_cmd, str(Path.cwd()), direction=direction, percent=50, parent_pane=parent_pane) + pane_id = backend.create_pane(full_cmd, str(Path.cwd()), direction=direction, percent=50, parent_pane=parent_pane) self.wezterm_panes[provider] = pane_id if provider == "codex": input_fifo = runtime / "input.fifo" output_fifo = runtime / "output.fifo" - # WezTerm 模式通过 pane 注入文本,不强依赖 FIFO;Windows/WSL 场景也不一定支持 mkfifo。 - self._write_codex_session(runtime, None, input_fifo, output_fifo, pane_id=pane_id) + # WezTerm mode injects text via pane, no strong FIFO dependency; Windows/WSL may not support mkfifo + self._write_codex_session(runtime, None, input_fifo, output_fifo, pane_id=pane_id, pane_title_marker=pane_title_marker) + self._write_cend_registry(os.environ.get("WEZTERM_PANE", ""), pane_id) + elif provider == "gemini": + self._write_gemini_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker) else: - self._write_gemini_session(runtime, None, pane_id=pane_id) + self._write_opencode_session(runtime, None, pane_id=pane_id, pane_title_marker=pane_title_marker) - print(f"✅ {provider.capitalize()} 已启动 (wezterm pane: {pane_id})") + print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='wezterm pane', pane_id=pane_id)}") return True def _start_provider_iterm2(self, provider: str) -> bool: @@ -190,7 +465,7 @@ class AILauncher: runtime.mkdir(parents=True, exist_ok=True) start_cmd = self._get_start_cmd(provider) - # iTerm2 分屏里,进程退出会导致 pane 直接关闭;默认保持 pane 打开便于查看退出信息。 + # In iTerm2 split, process exit will close pane; keep pane open by default to view exit info keep_open = os.environ.get("CODEX_ITERM2_KEEP_OPEN", "1").lower() not in {"0", "false", "no", "off"} if keep_open: start_cmd = ( @@ -200,14 +475,26 @@ class AILauncher: f"read -r _; " f"exit $code" ) - # Layout: first backend splits to the right of current pane, subsequent backends stack below - direction = "right" if not self.iterm2_panes else "bottom" - parent_pane = None - if direction == "bottom": - try: - parent_pane = next(iter(self.iterm2_panes.values())) - except StopIteration: - parent_pane = None + want_grid = {"codex", "gemini", "opencode"}.issubset(set(self.providers)) + current_pane = os.environ.get("ITERM_SESSION_ID") + if want_grid and current_pane: + if not self.iterm2_panes: + direction = "right" + parent_pane = current_pane + elif provider == "gemini": + direction = "bottom" + parent_pane = current_pane + else: + direction = "bottom" + parent_pane = self.iterm2_panes.get("codex") or next(iter(self.iterm2_panes.values())) + else: + direction = "right" if not self.iterm2_panes else "bottom" + parent_pane = None + if direction == "bottom": + try: + parent_pane = next(iter(self.iterm2_panes.values())) + except StopIteration: + parent_pane = None backend = Iterm2Backend() pane_id = backend.create_pane(start_cmd, str(Path.cwd()), direction=direction, percent=50, parent_pane=parent_pane) @@ -216,12 +503,14 @@ class AILauncher: if provider == "codex": input_fifo = runtime / "input.fifo" output_fifo = runtime / "output.fifo" - # iTerm2 模式通过 pane 注入文本,不强依赖 FIFO + # iTerm2 mode injects text via pane, no strong FIFO dependency self._write_codex_session(runtime, None, input_fifo, output_fifo, pane_id=pane_id) - else: + elif provider == "gemini": self._write_gemini_session(runtime, None, pane_id=pane_id) + else: + self._write_opencode_session(runtime, None, pane_id=pane_id) - print(f"✅ {provider.capitalize()} 已启动 (iterm2 session: {pane_id})") + print(f"✅ {t('started_backend', provider=provider.capitalize(), terminal='iterm2 session', pane_id=pane_id)}") return True def _work_dir_strings(self, work_dir: Path) -> list[str]: @@ -247,17 +536,35 @@ class AILauncher: try: if not path.exists(): return {} - data = json.loads(path.read_text()) + # Session files are written as UTF-8; on Windows PowerShell 5.1 the default encoding + # may not be UTF-8, so always decode explicitly and tolerate UTF-8 BOM. + raw = path.read_text(encoding="utf-8-sig") + data = json.loads(raw) return data if isinstance(data, dict) else {} except Exception: return {} def _write_json_file(self, path: Path, data: dict) -> None: try: - path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass + def _clear_codex_log_binding(self, data: dict) -> dict: + try: + if not isinstance(data, dict): + return {} + cleared = dict(data) + for key in ("codex_session_path", "codex_session_id", "codex_start_cmd"): + if key in cleared: + cleared.pop(key, None) + if cleared.get("active") is False: + cleared["active"] = True + return cleared + except Exception as exc: + print(f"⚠️ codex_session_clear_failed: {exc}", file=sys.stderr) + return data if isinstance(data, dict) else {} + def _claude_session_file(self) -> Path: return Path.cwd() / ".claude-session" @@ -265,16 +572,26 @@ class AILauncher: data = self._read_json_file(self._claude_session_file()) sid = data.get("claude_session_id") or data.get("session_id") if isinstance(sid, str) and sid.strip(): + # Guard against path-format mismatch (Windows case/slash differences, MSYS paths, etc.). + recorded_norm = _extract_session_work_dir_norm(data) + if not recorded_norm: + # Old/foreign session file without a recorded work dir: refuse to resume to avoid cross-project reuse. + return None + current_keys = _work_dir_match_keys(Path.cwd()) + if current_keys and recorded_norm not in current_keys: + return None return sid.strip() return None def _write_local_claude_session(self, session_id: str, active: bool = True) -> None: path = self._claude_session_file() data = self._read_json_file(path) + work_dir = Path.cwd() data.update( { "claude_session_id": session_id, - "work_dir": str(Path.cwd()), + "work_dir": str(work_dir), + "work_dir_norm": _normalize_path_for_match(str(work_dir)), "active": bool(active), "started_at": data.get("started_at") or time.strftime("%Y-%m-%d %H:%M:%S"), "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), @@ -286,31 +603,75 @@ class AILauncher: """ Returns (session_id, has_any_history_for_cwd). Session id is Codex CLI's UUID used by `codex resume `. + Always scans session logs to find the latest session for current cwd, + then updates local .codex-session file. """ - # Only trust local project state; deleting local dotfiles should reset resume behavior. project_session = Path.cwd() / ".codex-session" - if project_session.exists(): + + # Always scan Codex session logs for the latest session bound to this cwd. + # This ensures we get the latest session even if user did /clear during run. + root = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() + if not root.exists(): + return None, False + work_keys = _work_dir_match_keys(Path.cwd()) + if not work_keys: + return None, False + try: + logs = sorted( + (p for p in root.glob("**/*.jsonl") if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + except Exception: + logs = [] + for log_path in logs[:400]: + try: + with log_path.open("r", encoding="utf-8", errors="ignore") as handle: + first = handle.readline().strip() + except OSError: + continue + if not first: + continue try: - data = json.loads(project_session.read_text()) - cached = data.get("codex_session_id") - if isinstance(cached, str) and cached: - return cached, True + entry = json.loads(first) except Exception: - pass + continue + if not isinstance(entry, dict) or entry.get("type") != "session_meta": + continue + payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} + cwd = payload.get("cwd") + if not isinstance(cwd, str) or not cwd.strip(): + continue + if _normalize_path_for_match(cwd) not in work_keys: + continue + sid = payload.get("id") + if isinstance(sid, str) and sid: + # Update local .codex-session file with latest session id + data = self._read_json_file(project_session) if project_session.exists() else {} + work_dir = Path.cwd() + data.update({ + "codex_session_id": sid, + "codex_session_path": str(log_path), + "work_dir": str(work_dir), + "work_dir_norm": _normalize_path_for_match(str(work_dir)), + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + }) + self._write_json_file(project_session, data) + return sid, True return None, False def _build_codex_start_cmd(self) -> str: - cmd = "codex -c disable_paste_burst=true --full-auto" if self.auto else "codex -c disable_paste_burst=true" + cmd = "codex -c disable_paste_burst=true --dangerously-bypass-approvals-and-sandbox" if self.auto else "codex -c disable_paste_burst=true" codex_resumed = False if self.resume: session_id, has_history = self._get_latest_codex_session_id() if session_id: cmd = f"{cmd} resume {session_id}" - print(f"🔁 Resuming Codex session: {session_id[:8]}...") + print(f"🔁 {t('resuming_session', provider='Codex', session_id=session_id[:8])}") codex_resumed = True if not codex_resumed: - print("ℹ️ No Codex history found, starting fresh") + print(f"ℹ️ {t('no_history_fresh', provider='Codex')}") return cmd def _get_latest_gemini_project_hash(self) -> tuple[str | None, bool]: @@ -318,20 +679,40 @@ class AILauncher: Returns (project_hash, has_any_history_for_cwd). Gemini CLI stores sessions under ~/.gemini/tmp//chats/. """ - # Only trust local project state; deleting local dotfiles should reset resume behavior. - project_session = Path.cwd() / ".gemini-session" - if not project_session.exists(): - return None, False + import hashlib + + gemini_root = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() + + candidates: list[str] = [] try: - data = json.loads(project_session.read_text()) + candidates.append(str(Path.cwd().absolute())) except Exception: - data = {} - if not isinstance(data, dict): - return None, True - project_hash = data.get("gemini_project_hash") - if isinstance(project_hash, str) and project_hash.strip(): - return project_hash.strip(), True - return None, True + pass + try: + candidates.append(str(Path.cwd().resolve())) + except Exception: + pass + env_pwd = (os.environ.get("PWD") or "").strip() + if env_pwd: + try: + candidates.append(os.path.abspath(os.path.expanduser(env_pwd))) + except Exception: + candidates.append(env_pwd) + + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + project_hash = hashlib.sha256(candidate.encode()).hexdigest() + chats_dir = gemini_root / project_hash / "chats" + if not chats_dir.exists(): + continue + session_files = list(chats_dir.glob("session-*.json")) + if session_files: + return project_hash, True + + return None, False def _build_gemini_start_cmd(self) -> str: cmd = "gemini --yolo" if self.auto else "gemini" @@ -339,9 +720,9 @@ class AILauncher: _, has_history = self._get_latest_gemini_project_hash() if has_history: cmd = f"{cmd} --resume latest" - print("🔁 Resuming Gemini session...") + print(f"🔁 {t('resuming_session', provider='Gemini', session_id='')}") else: - print("ℹ️ No Gemini history found, starting fresh") + print(f"ℹ️ {t('no_history_fresh', provider='Gemini')}") return cmd def _warmup_provider(self, provider: str, timeout: float = 8.0) -> bool: @@ -349,6 +730,8 @@ class AILauncher: ping_script = self.script_dir / "bin" / "cping" elif provider == "gemini": ping_script = self.script_dir / "bin" / "gping" + elif provider == "opencode": + ping_script = self.script_dir / "bin" / "oping" else: return False @@ -365,6 +748,8 @@ class AILauncher: cwd=str(Path.cwd()), capture_output=True, text=True, + encoding='utf-8', + errors='replace', ) if last_result.returncode == 0: out = (last_result.stdout or "").strip() @@ -383,13 +768,67 @@ class AILauncher: def _get_start_cmd(self, provider: str) -> str: if provider == "codex": - # NOTE: Codex TUI 有 paste-burst 检测;终端注入(wezterm send-text/tmux paste-buffer) - # 往往会被识别为“粘贴”,导致回车仅换行不提交。默认关闭该检测,保证自动通信可用。 + # NOTE: Codex TUI has paste-burst detection; terminal injection (wezterm send-text/tmux paste-buffer) + # is often detected as "paste", causing Enter to only line-break not submit. Disable detection by default. return self._build_codex_start_cmd() elif provider == "gemini": return self._build_gemini_start_cmd() + elif provider == "opencode": + return self._build_opencode_start_cmd() return "" + def _build_opencode_start_cmd(self) -> str: + # OpenCode CLI (TUI). Allow override via env for custom wrappers. + cmd = (os.environ.get("OPENCODE_START_CMD") or "opencode").strip() or "opencode" + + # Auto mode: relax permissions and enable web tools via config file. + if self.auto: + self._ensure_opencode_auto_config() + + # Resume mode: continue last session + if self.resume: + cmd = f"{cmd} --continue" + + return cmd + + def _ensure_opencode_auto_config(self) -> None: + """ + Ensure project-local opencode.json exists with full allow permissions (auto mode). + Best-effort: never raise (should not block startup). + """ + try: + config_path = Path.cwd() / "opencode.json" + + desired = { + "permission": { + "edit": "allow", + "bash": "allow", + "skill": "allow", + "webfetch": "allow", + "doom_loop": "allow", + "external_directory": "allow", + } + } + + current: dict = {} + if config_path.exists(): + try: + current_raw = config_path.read_text(encoding="utf-8") + current_obj = json.loads(current_raw) + if isinstance(current_obj, dict): + current = current_obj + except Exception: + current = {} + + current["permission"] = dict(desired["permission"]) + + tmp_path = config_path.with_suffix(".tmp") + tmp_path.write_text(json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(tmp_path, config_path) + except Exception as exc: + # Non-fatal; OpenCode can still run with interactive permissions. + print(f"⚠️ Failed to update OpenCode config: {exc}", file=sys.stderr) + def _start_codex(self, tmux_session: str) -> bool: runtime = self.runtime_dir / "codex" runtime.mkdir(parents=True, exist_ok=True) @@ -431,7 +870,6 @@ CODEX_START_CMD={json.dumps(start_cmd)} if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then cd "$WORK_DIR" tmux new-session -d -s "$TMUX_SESSION" "$CODEX_START_CMD" - sleep 1 fi tmux pipe-pane -o -t "$TMUX_SESSION" "cat >> '$TMUX_LOG_FILE'" @@ -450,11 +888,14 @@ exec tmux attach -t "$TMUX_SESSION" terminal = self._detect_launch_terminal() if terminal == "tmux": - subprocess.run(["tmux", "new-session", "-d", "-s", f"launcher-{tmux_session}", str(script_file)], check=True) + if self._launch_script_in_macos_terminal(script_file): + pass + else: + subprocess.run(["tmux", "new-session", "-d", "-s", f"launcher-{tmux_session}", str(script_file)], check=True) else: subprocess.Popen([terminal, "-e", str(script_file)]) - print(f"✅ Codex 已启动 (tmux: {tmux_session})") + print(f"✅ {t('started_backend', provider='Codex', terminal='tmux', pane_id=tmux_session)}") return True def _start_gemini(self, tmux_session: str) -> bool: @@ -467,7 +908,7 @@ exec tmux attach -t "$TMUX_SESSION" self._write_gemini_session(runtime, tmux_session) - # 创建启动脚本 + # Create startup script wrapper = f'''#!/bin/bash cd "{os.getcwd()}" tmux new-session -d -s "{tmux_session}" 2>/dev/null || true @@ -479,26 +920,79 @@ exec tmux attach -t "$TMUX_SESSION" terminal = self._detect_launch_terminal() if terminal == "tmux": - # 纯 tmux 模式 - subprocess.run(["tmux", "new-session", "-d", "-s", tmux_session], check=True, cwd=os.getcwd()) - time.sleep(0.3) - subprocess.run(["tmux", "send-keys", "-t", tmux_session, start_cmd, "Enter"], check=True) + if self._launch_script_in_macos_terminal(script_file): + pass + else: + # Pure tmux mode + subprocess.run(["tmux", "new-session", "-d", "-s", tmux_session], check=True, cwd=os.getcwd()) + backend = TmuxBackend() + deadline = time.time() + 1.0 + sleep_s = 0.05 + while True: + try: + backend.send_text(tmux_session, start_cmd) + break + except subprocess.CalledProcessError: + if time.time() >= deadline: + raise + time.sleep(sleep_s) + sleep_s = min(0.2, sleep_s * 2) else: - # 打开新终端窗口 + # Open new terminal window subprocess.Popen([terminal, "--", str(script_file)]) - print(f"✅ Gemini 已启动 (tmux: {tmux_session})") + print(f"✅ {t('started_backend', provider='Gemini', terminal='tmux', pane_id=tmux_session)}") return True - def _write_codex_session(self, runtime, tmux_session, input_fifo, output_fifo, pane_id=None): + def _start_opencode(self, tmux_session: str) -> bool: + runtime = self.runtime_dir / "opencode" + runtime.mkdir(parents=True, exist_ok=True) + + start_cmd = self._build_opencode_start_cmd() + + script_file = runtime / "wrapper.sh" + + self._write_opencode_session(runtime, tmux_session) + + wrapper = f'''#!/bin/bash +\tcd "{os.getcwd()}" +\ttmux new-session -d -s "{tmux_session}" 2>/dev/null || true +\ttmux send-keys -t "{tmux_session}" "{start_cmd}" Enter +\texec tmux attach -t "{tmux_session}" +\t''' + script_file.write_text(wrapper) + os.chmod(script_file, 0o755) + + terminal = self._detect_launch_terminal() + if terminal == "tmux": + if self._launch_script_in_macos_terminal(script_file): + pass + else: + subprocess.run(["tmux", "new-session", "-d", "-s", f"launcher-{tmux_session}", str(script_file)], check=True) + elif terminal: + subprocess.Popen([terminal, "-e", str(script_file)]) + + print(f"✅ {t('started_backend', provider='OpenCode', terminal='tmux', pane_id=tmux_session)}") + return True + + def _write_codex_session(self, runtime, tmux_session, input_fifo, output_fifo, pane_id=None, pane_title_marker=None): session_file = Path.cwd() / ".codex-session" + + # Pre-check permissions + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + data = {} if session_file.exists(): - try: - data = json.loads(session_file.read_text()) - except Exception: - pass + data = self._read_json_file(session_file) + + if not self.resume: + data = self._clear_codex_log_binding(data) + work_dir = Path.cwd() data.update({ "session_id": self.session_id, "runtime_dir": str(runtime), @@ -507,26 +1001,88 @@ exec tmux attach -t "$TMUX_SESSION" "terminal": self.terminal_type, "tmux_session": tmux_session, "pane_id": pane_id, + "pane_title_marker": pane_title_marker, "tmux_log": str(runtime / "bridge_output.log"), - "work_dir": str(Path.cwd()), + "work_dir": str(work_dir), + "work_dir_norm": _normalize_path_for_match(str(work_dir)), "active": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), }) - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) - def _write_gemini_session(self, runtime, tmux_session, pane_id=None): + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + return True + + def _write_cend_registry(self, claude_pane_id: str, codex_pane_id: str | None) -> bool: + if not claude_pane_id: + return False + record = { + "ccb_session_id": self.session_id, + "claude_pane_id": claude_pane_id, + "codex_pane_id": codex_pane_id, + "work_dir": str(Path.cwd()), + } + ok = upsert_registry(record) + if not ok: + print("⚠️ Failed to update cpend registry", file=sys.stderr) + return ok + + def _write_gemini_session(self, runtime, tmux_session, pane_id=None, pane_title_marker=None): session_file = Path.cwd() / ".gemini-session" + + # Pre-check permissions + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + + data = { + "session_id": self.session_id, + "runtime_dir": str(runtime), + "terminal": self.terminal_type, + "tmux_session": tmux_session, + "pane_id": pane_id, + "pane_title_marker": pane_title_marker, + "work_dir": str(Path.cwd()), + "active": True, + "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + return True + + def _write_opencode_session(self, runtime, tmux_session, pane_id=None, pane_title_marker=None): + session_file = Path.cwd() / ".opencode-session" + + writable, reason, fix = check_session_writable(session_file) + if not writable: + print(f"❌ Cannot write {session_file.name}: {reason}", file=sys.stderr) + print(f"💡 Fix: {fix}", file=sys.stderr) + return False + data = { "session_id": self.session_id, "runtime_dir": str(runtime), "terminal": self.terminal_type, "tmux_session": tmux_session, "pane_id": pane_id, + "pane_title_marker": pane_title_marker, "work_dir": str(Path.cwd()), "active": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), } - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + + ok, err = safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) + if not ok: + print(err, file=sys.stderr) + return False + return True def _claude_project_dir(self, work_dir: Path) -> Path: projects_root = Path.home() / ".claude" / "projects" @@ -594,8 +1150,30 @@ exec tmux attach -t "$TMUX_SESSION" latest = max(uuid_sessions, key=lambda p: p.stat().st_mtime) return latest.stem, True + def _find_claude_cmd(self) -> str: + """Find Claude CLI executable""" + if sys.platform == "win32": + for cmd in ["claude.exe", "claude.cmd", "claude.bat", "claude"]: + path = shutil.which(cmd) + if path: + return path + npm_paths = [ + Path(os.environ.get("APPDATA", "")) / "npm" / "claude.cmd", + Path(os.environ.get("ProgramFiles", "")) / "nodejs" / "claude.cmd", + ] + for npm_path in npm_paths: + if npm_path.exists(): + return str(npm_path) + else: + path = shutil.which("claude") + if path: + return path + raise FileNotFoundError( + "❌ Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code" + ) + def _start_claude(self) -> int: - print("🚀 启动 Claude...") + print(f"🚀 {t('starting_claude')}") env = os.environ.copy() if "codex" in self.providers: @@ -624,49 +1202,75 @@ exec tmux attach -t "$TMUX_SESSION" else: env["GEMINI_TMUX_SESSION"] = self.tmux_sessions.get("gemini", "") - cmd = ["claude"] - if self.auto: + if "opencode" in self.providers: + runtime = self.runtime_dir / "opencode" + env["OPENCODE_SESSION_ID"] = self.session_id + env["OPENCODE_RUNTIME_DIR"] = str(runtime) + env["OPENCODE_TERMINAL"] = self.terminal_type + if self.terminal_type == "wezterm": + env["OPENCODE_WEZTERM_PANE"] = self.wezterm_panes.get("opencode", "") + elif self.terminal_type == "iterm2": + env["OPENCODE_ITERM2_PANE"] = self.iterm2_panes.get("opencode", "") + else: + env["OPENCODE_TMUX_SESSION"] = self.tmux_sessions.get("opencode", "") + + try: + claude_cmd = self._find_claude_cmd() + except FileNotFoundError as e: + print(str(e)) + return 1 + + cmd = [claude_cmd] + + # Check for .autoflow folder in current directory (CCA project) + autoflow_dir = Path.cwd() / ".autoflow" + if autoflow_dir.is_dir(): + # CCA project detected: use plan mode regardless of -a flag + cmd.extend(["--permission-mode", "plan"]) + elif self.auto: + # Auto mode without CCA: bypass all permissions cmd.append("--dangerously-skip-permissions") - local_session_id: str | None = None + else: + # If CCA (Claude Code Autoflow) is installed, start Claude in plan mode + cca_path, _ = _detect_cca() + if cca_path: + cmd.extend(["--permission-mode", "plan"]) if self.resume: - local_session_id = self._read_local_claude_session_id() - # Only resume if Claude can actually find the session on disk. - if local_session_id and (Path.home() / ".claude" / "session-env" / local_session_id).exists(): - cmd.extend(["--resume", local_session_id]) - print(f"🔁 Resuming Claude session: {local_session_id[:8]}...") + _, has_history = self._get_latest_claude_session_id() + if has_history: + cmd.append("--continue") + print(f"🔁 {t('resuming_claude', session_id='')}") else: - local_session_id = None - print("ℹ️ No local Claude session found, starting fresh") - - # Always start Claude with an explicit session id when not resuming, so the id is local and resettable. - if not local_session_id: - new_id = str(uuid.uuid4()) - cmd.extend(["--session-id", new_id]) - self._write_local_claude_session(new_id, active=True) - - print(f"📋 会话ID: {self.session_id}") - print(f"📁 运行目录: {self.runtime_dir}") - print(f"🔌 活跃后端: {', '.join(self.providers)}") + print(f"ℹ️ {t('no_claude_session')}") + + print(f"📋 Session ID: {self.session_id}") + print(f"📁 Runtime dir: {self.runtime_dir}") + print(f"🔌 Active backends: {', '.join(self.providers)}") print() - print("🎯 可用命令:") + print("🎯 Available commands:") if "codex" in self.providers: - print(" cask/cask-w/cping/cpend - Codex 通信") + print(" cask/cask-w/cping/cpend - Codex communication") if "gemini" in self.providers: - print(" gask/gask-w/gping/gpend - Gemini 通信") + print(" gask/gask-w/gping/gpend - Gemini communication") + if "opencode" in self.providers: + print(" oask/oask-w/oping/opend - OpenCode communication") print() - print(f"执行: {' '.join(cmd)}") + print(f"Executing: {' '.join(cmd)}") try: - return subprocess.run(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env).returncode + # Let subprocess inherit stdio by default. Explicitly passing sys.stdin/out/err + # can produce non-console handles on Windows (especially after re-wrapping + # stdout/stderr for UTF-8), which may trigger issues in Node-based CLIs. + return subprocess.run(cmd, env=env).returncode except KeyboardInterrupt: - print("\n⚠️ 用户中断") + print(f"\n⚠️ {t('user_interrupted')}") return 130 def cleanup(self): if self._cleaned: return self._cleaned = True - print("\n🧹 清理会话资源...") + print(f"\n🧹 {t('cleaning_up')}") if self.terminal_type == "wezterm": backend = WeztermBackend() @@ -683,13 +1287,15 @@ exec tmux attach -t "$TMUX_SESSION" subprocess.run(["tmux", "kill-session", "-t", tmux_session], stderr=subprocess.DEVNULL) subprocess.run(["tmux", "kill-session", "-t", f"launcher-{tmux_session}"], stderr=subprocess.DEVNULL) - for session_file in [Path.cwd() / ".codex-session", Path.cwd() / ".gemini-session", Path.cwd() / ".claude-session"]: + for session_file in [Path.cwd() / ".codex-session", Path.cwd() / ".gemini-session", Path.cwd() / ".opencode-session", Path.cwd() / ".claude-session"]: if session_file.exists(): try: - data = json.loads(session_file.read_text()) + data = self._read_json_file(session_file) + if not data: + continue data["active"] = False data["ended_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) except Exception: pass @@ -697,14 +1303,14 @@ exec tmux attach -t "$TMUX_SESSION" if self.runtime_dir.exists(): shutil.rmtree(self.runtime_dir, ignore_errors=True) - print("✅ 清理完成") + print(f"✅ {t('cleanup_complete')}") def run_up(self) -> int: git_info = _get_git_info() version_str = f"v{VERSION}" + (f" ({git_info})" if git_info else "") print(f"🚀 Claude Code Bridge {version_str}") print(f"📅 {time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"🔌 后端: {', '.join(self.providers)}") + print(f"🔌 Backends: {', '.join(self.providers)}") print("=" * 50) atexit.register(self.cleanup) @@ -714,19 +1320,19 @@ exec tmux attach -t "$TMUX_SESSION" providers = list(self.providers) if self.terminal_type in ("wezterm", "iterm2"): # Stable layout: codex on top, gemini on bottom (when both are present). - order = {"codex": 0, "gemini": 1} + order = {"codex": 0, "gemini": 1, "opencode": 2} providers.sort(key=lambda p: order.get(p, 99)) for provider in providers: if not self._start_provider(provider): return 1 - time.sleep(1) self._warmup_provider(provider) - time.sleep(2) + # Optional: start caskd after Codex session file exists (first startup convenience). + self._maybe_start_caskd() if self.no_claude: - print("✅ 后端已启动(--no-claude 模式)") + print(f"✅ {t('backends_started_no_claude')}") print() for provider in self.providers: if self.terminal_type == "wezterm": @@ -742,7 +1348,7 @@ exec tmux attach -t "$TMUX_SESSION" if tmux: print(f" {provider}: tmux attach -t {tmux}") print() - print(f"终止: ccb kill {' '.join(self.providers)}") + print(f"Kill: ccb kill {' '.join(self.providers)}") atexit.unregister(self.cleanup) return 0 @@ -763,17 +1369,17 @@ def cmd_up(args): def cmd_status(args): - providers = args.providers or ["codex", "gemini"] + providers = args.providers or ["codex", "gemini", "opencode"] results = {} for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - results[provider] = {"status": "未配置", "active": False} + results[provider] = {"status": "Not configured", "active": False} continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") active = data.get("active", False) @@ -791,16 +1397,16 @@ def cmd_status(args): alive = False results[provider] = { - "status": "运行中" if (active and alive) else "已停止", + "status": "Running" if (active and alive) else "Stopped", "active": active and alive, "terminal": terminal, "pane_id": pane_id, "runtime_dir": data.get("runtime_dir", ""), } except Exception as e: - results[provider] = {"status": f"错误: {e}", "active": False} + results[provider] = {"status": f"Error: {e}", "active": False} - print("📊 AI 后端状态:") + print(f"📊 {t('backend_status')}") for provider, info in results.items(): icon = "✅" if info.get("active") else "❌" print(f" {icon} {provider.capitalize()}: {info['status']}") @@ -811,16 +1417,16 @@ def cmd_status(args): def cmd_kill(args): - providers = args.providers or ["codex", "gemini"] + providers = args.providers or ["codex", "gemini", "opencode"] for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - print(f"⚠️ {provider}: 未找到会话文件") + print(f"⚠️ {provider}: Session file not found") continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") @@ -836,9 +1442,9 @@ def cmd_kill(args): data["active"] = False data["ended_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - session_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2)) - print(f"✅ {provider.capitalize()} 已终止") + print(f"✅ {provider.capitalize()} terminated") except Exception as e: print(f"❌ {provider}: {e}") @@ -851,11 +1457,11 @@ def cmd_restore(args): for provider in providers: session_file = Path.cwd() / f".{provider}-session" if not session_file.exists(): - print(f"⚠️ {provider}: 未找到会话文件") + print(f"⚠️ {provider}: Session file not found") continue try: - data = json.loads(session_file.read_text()) + data = json.loads(session_file.read_text(encoding="utf-8-sig")) terminal = data.get("terminal", "tmux") pane_id = data.get("pane_id") if terminal in ("wezterm", "iterm2") else data.get("tmux_session", "") active = data.get("active", False) @@ -882,15 +1488,16 @@ def cmd_restore(args): if provider == "codex": session_id = data.get("codex_session_id") if isinstance(session_id, str) and session_id: - has_history = True + recorded_norm = _extract_session_work_dir_norm(data) + work_keys = _work_dir_match_keys(Path.cwd()) + if recorded_norm and (not work_keys or recorded_norm in work_keys): + has_history = True + else: + session_id = None else: # Fallback: scan ~/.codex/sessions for latest session bound to this cwd. root = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() - work_dirs = set([os.environ.get("PWD", ""), str(Path.cwd())]) - try: - work_dirs.add(str(Path.cwd().resolve())) - except Exception: - pass + work_dirs = _work_dir_match_keys(Path.cwd()) try: logs = sorted( (p for p in root.glob("**/*.jsonl") if p.is_file()), @@ -915,7 +1522,7 @@ def cmd_restore(args): continue payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} cwd = payload.get("cwd") - if isinstance(cwd, str) and cwd in work_dirs: + if isinstance(cwd, str) and cwd.strip() and _normalize_path_for_match(cwd) in work_dirs: has_history = True sid = payload.get("id") if isinstance(sid, str) and sid: @@ -940,14 +1547,14 @@ def cmd_restore(args): break if has_history: - print(f"ℹ️ {provider}: 会话已结束,但可恢复历史会话") + print(f"ℹ️ {provider}: Session ended but history recoverable") if session_id: - print(f" 会话ID: {session_id[:8]}...") - print(f" 使用: ccb up {provider} -r") + print(f" Session ID: {session_id[:8]}...") + print(f" Use: ccb up {provider} -r") else: - print(f"⚠️ {provider}: 会话已结束,无可恢复历史") + print(f"⚠️ {provider}: Session ended, no recoverable history") else: - print(f"⚠️ {provider}: 会话已丢失,使用 ccb up {provider} -r 重启") + print(f"⚠️ {provider}: Session lost, use ccb up {provider} -r to restart") except Exception as e: print(f"❌ {provider}: {e}") @@ -955,9 +1562,287 @@ def cmd_restore(args): return 0 +def _get_version_info(dir_path: Path) -> dict: + """Get commit hash, date and version from install directory""" + info = {"commit": None, "date": None, "version": None} + ccb_file = dir_path / "ccb" + if ccb_file.exists(): + try: + content = ccb_file.read_text(encoding='utf-8', errors='replace') + for line in content.split('\n')[:60]: + line = line.strip() + if line.startswith('VERSION') and '=' in line: + info["version"] = line.split('=')[1].strip().strip('"').strip("'") + elif line.startswith('GIT_COMMIT') and '=' in line: + val = line.split('=')[1].strip().strip('"').strip("'") + if val: + info["commit"] = val + elif line.startswith('GIT_DATE') and '=' in line: + val = line.split('=')[1].strip().strip('"').strip("'") + if val: + info["date"] = val + except Exception: + pass + if shutil.which("git") and (dir_path / ".git").exists(): + result = subprocess.run( + ["git", "-C", str(dir_path), "log", "-1", "--format=%h|%ci"], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split("|") + if len(parts) >= 2: + info["commit"] = parts[0] + info["date"] = parts[1].split()[0] + return info + + +def _format_version_info(info: dict) -> str: + """Format version info for display""" + parts = [] + if info.get("version"): + parts.append(f"v{info['version']}") + if info.get("commit"): + parts.append(info["commit"]) + if info.get("date"): + parts.append(info["date"]) + return " ".join(parts) if parts else "unknown" + + +def _get_remote_version_info() -> dict | None: + """Get latest version info from GitHub API""" + import urllib.request + import ssl + + api_url = "https://api.github.com/repos/bfly123/claude_code_bridge/commits/main" + try: + ctx = ssl.create_default_context() + req = urllib.request.Request(api_url, headers={"User-Agent": "ccb"}) + with urllib.request.urlopen(req, context=ctx, timeout=5) as resp: + data = json.loads(resp.read().decode('utf-8')) + commit = data.get("sha", "")[:7] + date_str = data.get("commit", {}).get("committer", {}).get("date", "") + date = date_str[:10] if date_str else None + return {"commit": commit, "date": date} + except Exception: + pass + + if shutil.which("curl"): + result = subprocess.run( + ["curl", "-fsSL", api_url], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10 + ) + if result.returncode == 0: + try: + data = json.loads(result.stdout) + commit = data.get("sha", "")[:7] + date_str = data.get("commit", {}).get("committer", {}).get("date", "") + date = date_str[:10] if date_str else None + return {"commit": commit, "date": date} + except Exception: + pass + return None + + +def _detect_cca() -> tuple[str | None, str | None]: + """Detect CCA installation. Returns (cca_path, install_dir)""" + cca_path = shutil.which("cca") + if cca_path: + install_dir = Path(cca_path).resolve().parent + return cca_path, str(install_dir) + candidates = [ + Path.home() / ".local/share/claude_code_autoflow", + Path.home() / ".local/bin/cca", + ] + # Windows 特定路径 + if platform.system() == "Windows": + localappdata = os.environ.get("LOCALAPPDATA", "") + if localappdata: + candidates.extend([ + Path(localappdata) / "cca", + Path(localappdata) / "claude_code_autoflow", + Path(localappdata) / "cca" / "bin" / "cca.cmd", + Path(localappdata) / "cca" / "cca.ps1", + ]) + # 回退路径 + candidates.extend([ + Path.home() / "AppData/Local/cca", + Path.home() / "AppData/Local/claude_code_autoflow", + ]) + for p in candidates: + if p.exists(): + return str(p), str(p.parent if p.is_file() else p) + return None, None + + +def _get_cca_version(cca_path: str) -> str | None: + """Get CCA version by running cca -v""" + try: + result = subprocess.run( + [cca_path, "-v"], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip().split('\n')[0] + except Exception: + pass + return None + + +def cmd_version(args): + """Show version info and check for updates""" + script_root = Path(__file__).resolve().parent + # 候选目录列表(优先级递减) + candidates = [ + script_root, # 当前脚本目录 + Path(os.environ.get("CODEX_INSTALL_PREFIX", "")).expanduser() if os.environ.get("CODEX_INSTALL_PREFIX") else None, + Path.home() / ".local/share/codex-dual", # Linux/macOS + ] + + # Windows 特定路径 + if platform.system() == "Windows": + localappdata = os.environ.get("LOCALAPPDATA", "") + if localappdata: + candidates.extend([ + Path(localappdata) / "codex-dual", + Path(localappdata) / "claude-code-bridge", + ]) + candidates.append(Path.home() / "AppData/Local/codex-dual") + + # 选择第一个包含 ccb 文件的目录 + install_dir = None + for candidate in candidates: + if candidate and (candidate / "ccb").exists(): + install_dir = candidate + break + if not install_dir: + install_dir = script_root + + local_info = _get_version_info(install_dir) + local_str = _format_version_info(local_info) + + print(f"ccb (Claude Code Bridge) {local_str}") + print(f"Install path: {install_dir}") + + print("\nChecking for updates...") + remote_info = _get_remote_version_info() + + if remote_info is None: + print("⚠️ Unable to check for updates (network error)") + elif local_info.get("commit") and remote_info.get("commit"): + if local_info["commit"] == remote_info["commit"]: + print(f"✅ Up to date") + else: + remote_str = f"{remote_info['commit']} {remote_info.get('date', '')}" + print(f"📦 Update available: {remote_str}") + print(f" Run: ccb update") + else: + print("⚠️ Unable to compare versions") + + # CCA info + print() + cca_path, cca_dir = _detect_cca() + if cca_path: + cca_ver = _get_cca_version(cca_path) + print(f"cca (Claude Code Autoflow) {cca_ver or 'unknown'}") + print(f"Install path: {cca_dir}") + else: + print("cca (Claude Code Autoflow) not installed") + print(" Install: ccb update cca") + print(" Tip: CCA significantly enhances workflow automation") + + return 0 + + +def _update_cca(silent: bool = False) -> int: + """Install or update CCA""" + import urllib.request + import tarfile + import tempfile + + cca_repo = "https://github.com/bfly123/claude_code_autoflow" + cca_path, cca_dir = _detect_cca() + + # Try git pull if .git exists + if cca_dir and Path(cca_dir).exists() and (Path(cca_dir) / ".git").exists(): + if not silent: + print("🔄 Updating CCA via git pull...") + result = subprocess.run( + ["git", "-C", cca_dir, "pull", "--ff-only"], + capture_output=True, text=True, encoding="utf-8", errors="replace" + ) + if result.returncode == 0: + print("✅ CCA updated successfully") + return 0 + + # Fresh install via tarball + if not silent: + print("📦 Installing CCA from GitHub...") + # 根据操作系统选择安装目录 + if platform.system() == "Windows": + # Windows: 使用 %LOCALAPPDATA%\cca + localappdata = os.environ.get("LOCALAPPDATA", "") + if localappdata: + install_dir = Path(localappdata) / "cca" + else: + install_dir = Path.home() / "AppData/Local/cca" + else: + # Unix/Linux/macOS: 使用 ~/.local/share/claude_code_autoflow + install_dir = Path.home() / ".local/share/claude_code_autoflow" + tarball_url = f"{cca_repo}/archive/refs/heads/main.tar.gz" + + try: + with tempfile.TemporaryDirectory() as tmpdir: + tarball = Path(tmpdir) / "cca.tar.gz" + urllib.request.urlretrieve(tarball_url, tarball) + with tarfile.open(tarball, "r:gz") as tf: + tf.extractall(tmpdir) + extracted = Path(tmpdir) / "claude_code_autoflow-main" + if install_dir.exists(): + shutil.rmtree(install_dir) + shutil.copytree(extracted, install_dir) + # 根据操作系统选择安装脚本 + if platform.system() == "Windows": + # Windows: 优先使用 install.ps1 + install_script = install_dir / "install.ps1" + if install_script.exists(): + result = subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", str(install_script), "install"], + cwd=str(install_dir), + check=False + ) + if result.returncode != 0: + print("⚠️ PowerShell installation had issues, but CCA files are copied") + else: + # 回退:直接使用 cca.ps1 + cca_script = install_dir / "cca.ps1" + if cca_script.exists(): + print("⚠️ install.ps1 not found, but cca.ps1 is available") + print(f" You can run: powershell -File \"{cca_script}\" ") + else: + print("⚠️ Neither install.ps1 nor cca.ps1 found") + print(f" Files extracted to: {install_dir}") + else: + # Unix/Linux/macOS: 使用 install.sh + install_script = install_dir / "install.sh" + if install_script.exists(): + subprocess.run(["bash", str(install_script), "install"], cwd=str(install_dir)) + else: + print("⚠️ install.sh not found") + print("✅ CCA installed successfully") + print(f" Path: {install_dir}") + return 0 + except Exception as e: + print(f"❌ CCA install failed: {e}") + return 1 + + def cmd_update(args): """Update ccb to latest version""" - import shutil + # Handle "ccb update cca" subcommand + if hasattr(args, 'target') and args.target == 'cca': + return _update_cca() + import urllib.request import tarfile import tempfile @@ -970,6 +1855,9 @@ def cmd_update(args): install_dir = script_root repo_url = "https://github.com/bfly123/claude_code_bridge" + # Get current version info before update + old_info = _get_version_info(install_dir) + print("🔄 Checking for updates...") # Method 1: Prefer git if available @@ -977,21 +1865,80 @@ def cmd_update(args): print("📦 Updating via git pull...") result = subprocess.run( ["git", "-C", str(install_dir), "pull", "--ff-only"], - capture_output=True, text=True + capture_output=True, text=True, encoding='utf-8', errors='replace' ) if result.returncode == 0: print(result.stdout.strip() if result.stdout.strip() else "Already up to date.") print("🔧 Reinstalling...") subprocess.run([str(install_dir / "install.sh"), "install"]) - print("✅ Update complete!") + # Show upgrade info + new_info = _get_version_info(install_dir) + old_str = _format_version_info(old_info) + new_str = _format_version_info(new_info) + if old_info.get("commit") != new_info.get("commit"): + print(f"✅ Updated: {old_str} → {new_str}") + else: + print(f"✅ Already up to date: {new_str}") + # CCA update/suggest + cca_path, _ = _detect_cca() + if cca_path: + print("\n🔄 Updating CCA...") + _update_cca(silent=False) + else: + print("\n💡 CCA (Claude Code Autoflow) not installed") + print(" Install: ccb update cca") + print(" Tip: Significantly enhances workflow automation") return 0 else: print(f"⚠️ Git pull failed: {result.stderr.strip()}") print("Falling back to tarball download...") + def _pick_temp_base_dir() -> Path: + candidates: list[Path] = [] + for key in ("CCB_TMPDIR", "TMPDIR", "TEMP", "TMP"): + value = (os.environ.get(key) or "").strip() + if value: + candidates.append(Path(value).expanduser()) + try: + candidates.append(Path(tempfile.gettempdir())) + except Exception: + pass + candidates.extend( + [ + Path("/tmp"), + Path("/var/tmp"), + Path("/usr/tmp"), + Path.home() / ".cache" / "ccb" / "tmp", + install_dir / ".tmp", + Path.cwd() / ".tmp", + ] + ) + + for base in candidates: + try: + base.mkdir(parents=True, exist_ok=True) + probe = base / f".ccb_tmp_probe_{os.getpid()}_{int(time.time() * 1000)}" + probe.write_bytes(b"1") + probe.unlink(missing_ok=True) + return base + except Exception: + continue + + raise RuntimeError( + "❌ No usable temporary directory found.\n" + "Fix options:\n" + " - Create /tmp (Linux/WSL): sudo mkdir -p /tmp && sudo chmod 1777 /tmp\n" + " - Or set TMPDIR/CCB_TMPDIR to a writable path (e.g. export TMPDIR=$HOME/.cache/tmp)" + ) + # Method 2: Download tarball tarball_url = f"{repo_url}/archive/refs/heads/main.tar.gz" - tmp_dir = Path(tempfile.gettempdir()) / "ccb_update" + try: + tmp_base = _pick_temp_base_dir() + except Exception as exc: + print(str(exc)) + return 1 + tmp_dir = tmp_base / "ccb_update" try: print(f"📥 Downloading latest version...") @@ -1000,7 +1947,7 @@ def cmd_update(args): tmp_dir.mkdir(parents=True, exist_ok=True) tarball_path = tmp_dir / "main.tar.gz" - # 优先使用 curl/wget(更好的证书处理) + # Prefer curl/wget (better certificate handling) downloaded = False if shutil.which("curl"): result = subprocess.run( @@ -1015,12 +1962,12 @@ def cmd_update(args): ) downloaded = result.returncode == 0 if not downloaded: - # 回退到 urllib(可能有 SSL 问题) + # Fallback to urllib (may have SSL issues) import ssl try: urllib.request.urlretrieve(tarball_url, tarball_path) except ssl.SSLError: - print("⚠️ SSL 证书验证失败,尝试跳过验证...") + print("⚠️ SSL certificate verification failed, trying to skip...") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE @@ -1034,7 +1981,11 @@ def cmd_update(args): member_path = (dest / member.name).resolve() if not str(member_path).startswith(str(dest) + os.sep): raise RuntimeError(f"Unsafe tar member path: {member.name}") - tar.extractall(dest) + # Python 3.14+ requires filter argument + try: + tar.extractall(dest, filter='data') + except TypeError: + tar.extractall(dest) with tarfile.open(tarball_path, "r:gz") as tar: _safe_extract(tar, tmp_dir) @@ -1044,10 +1995,33 @@ def cmd_update(args): print("🔧 Installing...") env = os.environ.copy() env["CODEX_INSTALL_PREFIX"] = str(install_dir) - subprocess.run([str(extracted_dir / "install.sh"), "install"], check=True, env=env) - - print("✅ Update complete!") - print("💡 推荐:安装 WezTerm 作为终端前端(分屏/滚动体验更好),详情见 README。") + # Windows: use install.ps1, Unix: use install.sh + if platform.system() == "Windows": + ps1_script = extracted_dir / "install.ps1" + subprocess.run( + ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(ps1_script), "install"], + check=True, env=env + ) + else: + subprocess.run([str(extracted_dir / "install.sh"), "install"], check=True, env=env) + + # Show upgrade info + new_info = _get_version_info(install_dir) + old_str = _format_version_info(old_info) + new_str = _format_version_info(new_info) + if old_info.get("commit") != new_info.get("commit") or old_info.get("version") != new_info.get("version"): + print(f"✅ Updated: {old_str} → {new_str}") + else: + print(f"✅ Already up to date: {new_str}") + # CCA update/suggest + cca_path, _ = _detect_cca() + if cca_path: + print("\n🔄 Updating CCA...") + _update_cca(silent=False) + else: + print("\n💡 CCA (Claude Code Autoflow) not installed") + print(" Install: ccb update cca") + print(" Tip: Significantly enhances workflow automation") return 0 except Exception as e: @@ -1060,32 +2034,43 @@ def cmd_update(args): def main(): - parser = argparse.ArgumentParser(description="Claude AI 统一启动器", add_help=True) - subparsers = parser.add_subparsers(dest="command", help="子命令") + parser = argparse.ArgumentParser(description="Claude AI unified launcher", add_help=True) + subparsers = parser.add_subparsers(dest="command", help="Subcommands") - # up 子命令 - up_parser = subparsers.add_parser("up", help="启动 AI 后端") - up_parser.add_argument("providers", nargs="*", choices=["codex", "gemini"], help="要启动的后端") - up_parser.add_argument("-r", "--resume", action="store_true", help="恢复上下文") - up_parser.add_argument("-a", "--auto", action="store_true", help="全自动权限模式") - up_parser.add_argument("--no-claude", action="store_true", help="不启动 Claude 主窗口") + # up subcommand + up_parser = subparsers.add_parser("up", help="Start AI backends") + up_parser.add_argument("providers", nargs="*", choices=["codex", "gemini", "opencode"], help="Backends to start") + up_parser.add_argument("-r", "--resume", "--restore", action="store_true", help="Resume context") + up_parser.add_argument("-a", "--auto", action="store_true", help="Full auto permission mode") + up_parser.add_argument("--no-claude", action="store_true", help="Don't start Claude main window") - # status 子命令 - status_parser = subparsers.add_parser("status", help="检查状态") - status_parser.add_argument("providers", nargs="*", default=[], help="要检查的后端 (codex/gemini)") + # status subcommand + status_parser = subparsers.add_parser("status", help="Check status") + status_parser.add_argument("providers", nargs="*", default=[], help="Backends to check (codex/gemini/opencode)") - # kill 子命令 - kill_parser = subparsers.add_parser("kill", help="终止会话") - kill_parser.add_argument("providers", nargs="*", default=[], help="要终止的后端 (codex/gemini)") + # kill subcommand + kill_parser = subparsers.add_parser("kill", help="Terminate session") + kill_parser.add_argument("providers", nargs="*", default=[], help="Backends to terminate (codex/gemini/opencode)") - # restore 子命令 - restore_parser = subparsers.add_parser("restore", help="恢复/attach 会话") - restore_parser.add_argument("providers", nargs="*", default=[], help="要恢复的后端 (codex/gemini)") + # restore subcommand + restore_parser = subparsers.add_parser("restore", help="Restore/attach session") + restore_parser.add_argument("providers", nargs="*", default=[], help="Backends to restore (codex/gemini/opencode)") - # update 子命令 + # update subcommand update_parser = subparsers.add_parser("update", help="Update to latest version") + update_parser.add_argument("target", nargs="?", choices=["cca"], + help="Optional: 'cca' to install/update CCA only") + + # version subcommand + subparsers.add_parser("version", help="Show version and check for updates") - args = parser.parse_args() + argv = sys.argv[1:] + # Backward/shortcut compatibility + if argv and argv[0] in {"-r", "--resume", "--restore"}: + argv = ["up"] + argv + elif argv and argv[0] in {"-v", "--version"}: + argv = ["version"] + args = parser.parse_args(argv) if not args.command: parser.print_help() @@ -1101,6 +2086,8 @@ def main(): return cmd_restore(args) elif args.command == "update": return cmd_update(args) + elif args.command == "version": + return cmd_version(args) else: parser.print_help() return 1 diff --git a/codex_skills/oask/SKILL.md b/codex_skills/oask/SKILL.md new file mode 100644 index 0000000..6c0c601 --- /dev/null +++ b/codex_skills/oask/SKILL.md @@ -0,0 +1,35 @@ +--- +name: oask +description: Delegate a task to OpenCode via the existing `oask` command (stdin-safe). Use only when the user explicitly asks to delegate to OpenCode (ask/@opencode/let opencode/review); NOT for questions about OpenCode itself. +--- + +# Delegate to OpenCode (oask) + +This skill lets Codex delegate work to OpenCode by running the existing `oask` command. + +## Trigger Conditions + +Use this skill ONLY when the user explicitly delegates to OpenCode, e.g.: +- "@opencode …", "ask opencode …", "let opencode …" +- "use opencode to review/analyze/debug …" + +DO NOT use this skill when: +- The user asks questions *about* OpenCode itself (how it works, install, config, etc.) +- The user mentions OpenCode without delegation intent + +## Prerequisites / Troubleshooting + +- OpenCode backend must be running: `ccb up opencode` +- If unsure, check status: `ccb status opencode` + +## Execution + +Run `oask` and pass the full user request via stdin (prevents shell backtick command substitution): + +```sh +oask <<'EOF' + +EOF +``` + +Then relay OpenCode’s reply back to the user. diff --git a/commands/cask-w.md b/commands/cask-w.md index 20ee42f..e9ffaad 100644 --- a/commands/cask-w.md +++ b/commands/cask-w.md @@ -1,24 +1,12 @@ -通过 `cask-w` 命令将指令转发到 Codex 会话并等待回复(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 +WARNING: Only use when user EXPLICITLY requests sync/foreground mode. +Do NOT use as alternative to cask. -执行方式: -- 沉默运行 `Bash(cask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `cask-w <转发内容>`,效果等同 +Send message to Codex via `cask-w` (foreground sync). -参数说明: -- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令) -- ⚠️ 注意:cask-w 转发后会等待 Codex 回复(默认无超时,`Ctrl-C` 可中断) +Execution: `Bash(cask-w "")` -交互流程: -1. 转发命令到 Codex tmux 会话 -2. 捕获当前日志状态 -3. 等待新的回复消息 -4. 打印回复内容后返回 +Parameters: +- `` required +- `--timeout SECONDS` optional -示例: -- `Bash(cask-w "pwd")` -- `Bash(cask-w "echo test")` - -提示: -- cask-w 会阻塞等待 Codex 回复,适合需要立即看到结果的场景 -- 如不需要等待,使用 `/cask` 即可 -- cask-w 返回后表示已获取回复,无需继续执行任何操作 +Output: stdout = reply, exit code 0 = success diff --git a/commands/cask.md b/commands/cask.md deleted file mode 100644 index 75a12e0..0000000 --- a/commands/cask.md +++ /dev/null @@ -1,18 +0,0 @@ -通过 `cask` 命令将指令转发到 Codex 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 - -执行方式: -- 沉默运行 `Bash(cask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `cask <转发内容>`,效果等同 - -参数说明: -- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令) -- ⚠️ 注意:cask 仅负责转发,不在本 Claude 进程执行,返回后立即结束 - -示例: -- `Bash(cask "pwd")` -- `Bash(cask "ls -la")` - -提示: -- cask 发送命令后立即返回,不等待结果 -- 如需等待 Codex 回复,请使用 `/cask-w` -- cask 返回后表示转发完成,无需继续执行任何操作 diff --git a/commands/caskd.md b/commands/caskd.md new file mode 100644 index 0000000..6909368 --- /dev/null +++ b/commands/caskd.md @@ -0,0 +1,12 @@ +Start/stop the `caskd` daemon (Codex, WezTerm/iTerm2). + +Execution: +- `Bash(caskd)` to start (runs until stopped) +- `Bash(caskd --shutdown)` to stop + +Notes: +- `cask` will use `caskd` automatically when running and `CCB_CASKD=1` (default). +- `caskd` currently supports WezTerm/iTerm2 sessions only (tmux falls back to direct mode). +- State file: `~/.ccb/run/caskd.json` (override with `CCB_CASKD_STATE_FILE` / `--state-file`). +- Autostart: set `CCB_CASKD_AUTOSTART=0` (or legacy `CCB_AUTO_CASKD=0`) to disable auto-start behavior. +- Listen address: set `CCB_CASKD_LISTEN=127.0.0.1:0` (or pass `--listen host:port`). diff --git a/commands/cpend.md b/commands/cpend.md index 6f2423a..bac063d 100644 --- a/commands/cpend.md +++ b/commands/cpend.md @@ -1,19 +1,13 @@ -使用 `cpend` 从 Codex 官方日志中抓取最新回复,适合异步模式或超时后的补充查询。 +Use `cpend` to fetch latest reply from Codex logs. -执行方式: -- Claude 端使用 `Bash(cpend)`,命令执行过程保持静默 -- 本地终端可直接运行 `cpend` +WARNING: Only use when user EXPLICITLY requests. Do NOT use proactively after cask. -功能特点: -1. 解析 `.codex-session` 记录的日志路径,定位本次会话的最新 JSONL 文件 -2. 读取尾部消息并返回 Codex 最近一次输出 -3. 若无新内容,将提示“暂无 Codex 回复” +Trigger conditions (ALL must match): +- User EXPLICITLY mentions cpend/Cpend +- Or user asks to "view codex reply" / "show codex response" -常见场景: -- `cask` 异步提交多条任务后集中查看结果 -- `cask-w` 因超时退出后手动确认 Codex 是否已回应 -- 需要核对 Codex 回复与原始问题是否匹配 +Execution: +- `cpend` - fetch latest reply: `Bash(cpend)` +- `cpend N` - fetch last N Q&A pairs: `Bash(cpend N)` -提示: -- 日志文件通常位于 `~/.codex/sessions/.../rollout-.jsonl` -- 如果命令返回空,请先确认 Codex 会话仍在运行(可用 `/cping` 检查) +Output: stdout = reply text, exit code 0 = success, 2 = no reply diff --git a/commands/cping.md b/commands/cping.md index 5b3a5bc..aca3e91 100644 --- a/commands/cping.md +++ b/commands/cping.md @@ -1,19 +1,9 @@ -使用 `cping` 检查当前 Codex 会话是否健康,快速定位通信问题。 +Test connectivity with Codex via `cping` command. -执行方式: -- Claude 端运行 `Bash(cping)`,无需输出命令执行过程 -- 本地终端直接执行 `cping` +Execution: +- Run silently `Bash(cping)`, no additional analysis or operations after execution +- Can also run `cping` in terminal with same effect -检测内容: -1. `.codex-session` 是否标记为活跃,运行目录是否存在 -2. tmux 模式:FIFO 管道是否仍可访问 -3. tmux 模式:Codex 侧进程是否存活(根据 `codex.pid` 验证) -4. WezTerm 模式:pane 是否仍存在(根据 `wezterm cli list` 检测) - -输出说明: -- 成功:`✅ Codex连接正常 (...)` -- 失败:列出缺失的组件或异常信息,便于进一步排查 - -提示: -- 若检测失败,可尝试重新运行 `ccb up codex` 或查看桥接日志 -- 在多次超时或无回复时,先执行 `cping` 再决定是否重启会话 +Hints: +- Returns Codex session status +- Used to check if Codex is running normally diff --git a/commands/gask-w.md b/commands/gask-w.md index 62e88e3..49b0f7f 100644 --- a/commands/gask-w.md +++ b/commands/gask-w.md @@ -1,18 +1,12 @@ -通过 `gask-w` 命令将指令转发到 Gemini 会话,并同步等待回复(支持 tmux / WezTerm)。 +WARNING: Only use when user EXPLICITLY requests sync/foreground mode. +Do NOT use as alternative to gask. -执行方式: -- 沉默运行 `Bash(gask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gask-w <转发内容>`,效果等同 +Send message to Gemini via `gask-w` (foreground sync). -参数说明: -- `<转发内容>` 必填,会被转发到 Gemini 会话 -- ⚠️ 注意:gask-w 会等待 Gemini 回复后再返回 +Execution: `Bash(gask-w "")` -示例: -- `Bash(gask-w "解释一下这段代码")` -- `Bash(gask-w "这个方案有什么建议?")` +Parameters: +- `` required +- `--timeout SECONDS` optional -提示: -- gask-w 会阻塞等待 Gemini 回复 -- 默认无超时,`Ctrl-C` 可中断(如需非阻塞,使用 `/gask`) -- 适合需要获取 Gemini 反馈的场景 +Output: stdout = reply, exit code 0 = success diff --git a/commands/gask.md b/commands/gask.md deleted file mode 100644 index 2a0a8cf..0000000 --- a/commands/gask.md +++ /dev/null @@ -1,18 +0,0 @@ -通过 `gask` 命令将指令转发到 Gemini 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。 - -执行方式: -- 沉默运行 `Bash(gask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gask <转发内容>`,效果等同 - -参数说明: -- `<转发内容>` 必填,会被转发到 Gemini 会话 -- ⚠️ 注意:gask 仅负责转发,不在本 Claude 进程执行,返回后立即结束 - -示例: -- `Bash(gask "解释一下这段代码")` -- `Bash(gask "帮我优化这个函数")` - -提示: -- gask 发送命令后立即返回,不等待结果 -- 如需等待 Gemini 回复,请使用 `/gask-w` -- gask 返回后表示转发完成,无需继续执行任何操作 diff --git a/commands/gpend.md b/commands/gpend.md index 86b197b..5008409 100644 --- a/commands/gpend.md +++ b/commands/gpend.md @@ -1,9 +1,13 @@ -通过 `gpend` 命令查看 Gemini 最新回复。 +Use `gpend` to fetch latest reply from Gemini logs. -执行方式: -- 沉默运行 `Bash(gpend)`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gpend`,效果等同 +WARNING: Only use when user EXPLICITLY requests. Do NOT use proactively after gask. -提示: -- 用于查看 gask 异步发送后的回复 -- 或 gask-w 超时后继续获取回复 +Trigger conditions (ALL must match): +- User EXPLICITLY mentions gpend/Gpend +- Or user asks to "view gemini reply" / "show gemini response" + +Execution: +- `gpend` - fetch latest reply: `Bash(gpend)` +- `gpend N` - fetch last N Q&A pairs: `Bash(gpend N)` + +Output: stdout = reply text, exit code 0 = success, 2 = no reply diff --git a/commands/gping.md b/commands/gping.md index e284a09..a02b33a 100644 --- a/commands/gping.md +++ b/commands/gping.md @@ -1,9 +1,9 @@ -通过 `gping` 命令测试与 Gemini 的连通性。 +Test connectivity with Gemini via `gping` command. -执行方式: -- 沉默运行 `Bash(gping)`,执行完毕后不做其他分析、推理或额外操作 -- 在终端中也可执行 `gping`,效果等同 +Execution: +- Run silently `Bash(gping)`, no additional analysis or operations after execution +- Can also run `gping` in terminal with same effect -提示: -- 返回 Gemini 会话状态 -- 用于检查 Gemini 是否正常运行 +Hints: +- Returns Gemini session status +- Used to check if Gemini is running normally diff --git a/commands/oask-w.md b/commands/oask-w.md new file mode 100644 index 0000000..df8b90f --- /dev/null +++ b/commands/oask-w.md @@ -0,0 +1,13 @@ +WARNING: Only use when user EXPLICITLY requests sync/foreground mode. +Do NOT use as alternative to oask. + +Send message to OpenCode via `oask-w` (foreground sync). + +Execution: `Bash(oask-w "")` + +Parameters: +- `` required +- `--timeout SECONDS` optional + +Output: stdout = reply, exit code 0 = success + diff --git a/commands/opend.md b/commands/opend.md new file mode 100644 index 0000000..dc9d9b7 --- /dev/null +++ b/commands/opend.md @@ -0,0 +1,13 @@ +Use `opend` to fetch latest reply from OpenCode storage. + +WARNING: Only use when user EXPLICITLY requests. Do NOT use proactively after oask. + +Trigger conditions (ALL must match): +- User EXPLICITLY mentions opend/Opend +- Or user asks to "view opencode reply" / "show opencode response" + +Execution: +- `opend` - fetch latest reply: `Bash(opend)` + +Output: stdout = reply text, exit code 0 = success, 2 = no reply + diff --git a/commands/oping.md b/commands/oping.md new file mode 100644 index 0000000..1ea5fee --- /dev/null +++ b/commands/oping.md @@ -0,0 +1,10 @@ +Test connectivity with OpenCode via `oping` command. + +Execution: +- Run silently `Bash(oping)`, no additional analysis or operations after execution +- Can also run `oping` in terminal with same effect + +Hints: +- Returns OpenCode session status +- Used to check if OpenCode is running normally + diff --git a/docs/caskd-wezterm-daemon-plan.md b/docs/caskd-wezterm-daemon-plan.md new file mode 100644 index 0000000..a1d35fe --- /dev/null +++ b/docs/caskd-wezterm-daemon-plan.md @@ -0,0 +1,357 @@ +# 方案2(最终形态):`caskd`(WezTerm)——稳定、跨系统、可并行的 Codex 同步问答守护进程(详细执行方案) + +适用范围:本仓库当前架构(`bin/cask` 发送;`lib/codex_comm.py` 从 `~/.codex/sessions/**/*.jsonl` 读回;WezTerm 模式通过 `wezterm cli send-text` 注入)。 + +目标:把 `cask` 的“回复提取”从“读到第一条 assistant 就返回”升级为“请求↔回复强关联”,并在 **不同工作目录** 使用 **不同 Codex session/pane** 时实现真正并行(互不阻塞、互不串台)。 + +--- + +## 1. 现状与问题(为什么要上方案2) + +### 1.1 现状(简述) +- **发送路径(WezTerm)**:直接把用户文本注入 Codex TUI pane(不带可关联的请求 ID)。 +- **接收路径**:从 Codex 官方 JSONL 日志中“从 offset 往后读”,抽取到一条 assistant 文本就返回。 +- **完成判定**:依赖一个固定字符串 marker(默认 `EXECUTION_COMPLETE`),且通常是“最后一行包含子串即完成”。 + +### 1.2 核心不稳定点(必须解决) +- **缺乏请求↔回复关联**:日志读取只认“下一条 assistant”,任何手动输入/其他脚本/并发请求都可能造成串台。 +- **并发粒度不对**:当前锁偏向“按目录”而不是“按 session”,不同目录共享同一 session 时会绕开锁,导致乱序注入 + 互读对方回复。 +- **marker 误判/短路**:固定子串既可能被用户问题触发导致不追加结束约束,也可能被模型自然输出触发误判完成。 + +--- + +## 2. 方案2总览(核心原则:三件事缺一不可) + +### 2.1 三个核心策略 +1) **请求级 `req_id` 协议**:每次请求生成高熵 `req_id`,把它写进发给 Codex 的 user 消息中,并要求 assistant 以严格的独占行 `CCB_DONE: ` 结束。 +2) **日志锚定配对**:从 JSONL 日志里先定位该 `req_id` 的 user 锚点,再收集 assistant 输出直到 `CCB_DONE: `。 +3) **session 粒度队列**:同一 `session_key`(同一 Codex session/pane)只能串行发送;不同 `session_key` 并行。 + +### 2.2 为什么需要守护进程 `caskd` +即便你把“锚定配对”做对了,如果多个 `cask` 进程同时调用 `wezterm cli` 注入,仍会出现: +- 注入乱序/交错(TUI 输入通道是单路串行的); +- 多进程反复扫描日志,性能差且容易踩到边界; +- `.codex-session` 的 session 绑定无法被统一修正与跟随更新。 + +`caskd` 把这些问题集中治理: +- 每个 session 一个发送队列(串行化注入); +- 每个 session 一个日志 tailer(复用读取,按 `req_id` 分发); +- 统一维护 `.codex-session`(根据“锚点观测”动态修正)。 + +--- + +## 3. 设计目标与非目标 + +### 3.1 设计目标 +- **稳定**:不串台;不误判完成;对日志切换/会话恢复更鲁棒。 +- **跨系统**:Linux/macOS/Windows/WSL 统一行为(WezTerm 为主)。 +- **可并行**: + - 不同 `session_key`:并行执行(互不影响)。 + - 同一 `session_key`:并行等待、串行发送(物理限制)。 +- **兼容现有 CLI**:`cask` 参数、stdout/stderr、退出码、`--output` 行为不变。 + +### 3.2 非目标(明确边界) +- 不试图在同一 Codex TUI session 内实现真正并行“同时执行多个请求”(输入通道与上下文决定了必须串行提交)。 +- 不依赖 Codex 官方提供 request_id(当前日志结构不稳定,不能假设存在)。 + +--- + +## 4. 组件与职责 + +### 4.1 新增/改造组件 +- `bin/cask`:客户端(保持用户接口);优先走 daemon;失败可 fallback(可配置)。 +- `bin/caskd`:守护进程;提供本地 RPC;管理 per-session worker、日志 tail、`.codex-session` 更新。 + +### 4.2 复用既有组件 +- `lib/terminal.py`:复用 `WeztermBackend.send_text()`(enter 重试、paste/no-paste 策略、WSL/Windows 兼容)。 +- `lib/codex_comm.py`:复用 JSONL tail 框架,但需要扩展成 **事件级**(同时识别 user+assistant)。 +- `lib/process_lock.py`:可用于 `caskd` 单实例锁(全局 lockfile),也可用于 client fallback 模式。 + +--- + +## 5. 协议(最关键的稳定性来源) + +### 5.1 Codex 对话内协议(req_id + done 行) + +#### req_id 生成 +推荐:`uuid4().hex` 或 `secrets.token_hex(16)`(>=128bit)。 + +#### 发送给 Codex 的最终文本模板(必须严格) +要求: +- `CCB_REQ_ID` 行必须是独占一行; +- `CCB_DONE` 行必须是 **最后一个非空行**; +- 完成判定必须是严格匹配,不用子串包含。 + +模板: +```text +CCB_REQ_ID: + +<用户原始 message> + +IMPORTANT: +- Reply normally. +- End your reply with this exact final line (verbatim, on its own line): +CCB_DONE: +``` + +#### 完成判定与剥离 +- 将已收集文本按行 `splitlines()`; +- 找到最后一个非空行; +- **仅当**其严格等于 `CCB_DONE: ` 才视为完成; +- 返回时剥离该行(以及其后的空白)。 + +> 这一步直接规避现有固定 marker 的短路/误判。 + +--- + +### 5.2 `cask` ↔ `caskd` IPC 协议(跨系统稳定) + +#### 传输 +- 本地 TCP:`127.0.0.1:`(跨平台统一;避免 Unix domain socket 在 Windows 的坑)。 +- 帧格式:newline-delimited JSON(每行一个 JSON,对调试友好)。 +- 认证:daemon 启动生成随机 token;客户端请求必须带 token(状态文件权限 0600)。 + +#### 请求格式(示例) +```json +{ + "type": "cask.request", + "v": 1, + "id": "client_req_uuid", + "work_dir": "/path/to/proj", + "timeout_s": 3600, + "quiet": false, + "message": "..." +} +``` + +#### 响应格式(示例) +```json +{ + "type": "cask.response", + "v": 1, + "id": "client_req_uuid", + "req_id": "generated_req_id", + "exit_code": 0, + "reply": "...", + "meta": { + "session_key": "...", + "log_path": "...", + "fallback_scan": false, + "anchor_seen": true, + "done_seen": true, + "anchor_ms": 120, + "done_ms": 980 + } +} +``` + +#### 退出码(与现有 `cli_output` 对齐) +- `0`:完成(命中 `CCB_DONE:`)。 +- `2`:超时/未完成(允许返回 partial)。 +- `1`:错误(session 不健康、pane 不存在、日志不可读等)。 + +--- + +## 6. Session 路由、绑定与 `.codex-session` 动态维护 + +### 6.1 `.codex-session` 的定位规则(work_dir → session 文件) +对请求中的 `work_dir` 执行“向上查找”: +- `work_dir/.codex-session` +- `work_dir/../.codex-session` +- …直到根目录 + +> 注意:daemon **不能**使用 `Path.cwd()` 这种隐式行为,必须全程以入参 `work_dir` 为准。 + +### 6.2 session_key 的选择(队列/锁粒度) +优先级: +1) `.codex-session.codex_session_id`(最稳定,来自官方 log 的 UUID)。 +2) `.codex-session.pane_title_marker`(WezTerm 可重定位)。 +3) `.codex-session.pane_id`(最后兜底;pane 可能重建)。 + +### 6.3 WezTerm pane 自愈(防 pane_id 过期) +发送前检查: +- 如果 `pane_id` 不存在:用 `pane_title_marker` 调 `WeztermBackend.find_pane_by_title_marker(marker)` 重定位。 +- 重定位成功则更新 `.codex-session.pane_id`(可选,但推荐)。 + +### 6.4 “绑定不会失败”的关键:以 req_id 锚点观测为真相 +`.codex-session` 仅作为缓存提示。真正绑定由本次请求的 `req_id` 决定: +- 在某个 `log_path` 中观测到 user 锚点(包含 `CCB_REQ_ID:`)或 done 行(包含 `CCB_DONE:`),即可确认该请求属于此 `log_path`/session。 + +### 6.5 跟随对话更新(session 切换/日志轮转) +当观测到 `req_id` 实际落在某个 `log_path` 后: +1) 从 `log_path` 提取 `codex_session_id`(文件名或首行 `session_meta` 等)。 +2) 写回 `.codex-session`: + - `codex_session_path` + - `codex_session_id` + - `updated_at` + - `active`(必要时修正) + +推荐复用本仓库已有的更新逻辑思路(类似 `CodexCommunicator._remember_codex_session(log_path)`),但要做到 **可传入 session_file 路径**。 + +> 结果:用户在 pane 内 `codex resume` 切换会话后,下一次请求会命中新 log 并自动更新 `.codex-session`,而不是靠 mtime 猜。 + +--- + +## 7. 日志读取:事件级 tail(user+assistant) + +### 7.1 为什么必须事件级 +只抽 assistant 文本无法定位 user 锚点,无法构建“从锚点开始收集”的窗口,串台问题无法根治。 + +### 7.2 事件抽取要求 +从每行 JSONL entry 中抽取: +- `role`:`user` 或 `assistant`(最低要求) +- `text`:对应消息的文本(可能是多段 content 拼接) +- 其他:可选 `timestamp`、`entry_type`、`session_id` + +实现建议: +- 将现有逻辑整合成单一函数:`extract_event(entry) -> Optional[(role, text)]`。 +- 兼容多种 entry type(`response_item`、`event_msg`、以及 fallback `payload.role`)。 + +### 7.3 读取算法(稳健点) +复用现有 tailing 关键点: +- 二进制 `readline()`; +- 若末行无 `\n`,回退 offset 等待 writer 续写(避免 JSON 半行)。 + +### 7.4 req_id 状态机(per request watcher) +每个 `req_id` 一个 watcher,状态: +- `WAIT_ANCHOR`:等待 `role=user` 且 `text` 含 `CCB_REQ_ID:` +- `COLLECTING`:收集 `role=assistant` 的文本片段 +- `DONE`:assembled 文本最后非空行严格等于 `CCB_DONE:` +- `TIMEOUT`:超时,返回 partial(exit_code=2) + +### 7.5 fallback 扫描(保证锚点总能找到) +触发条件:发送后在 `preferred_log` 中 T 秒(建议 2~3s)未命中锚点(或 log 不存在)。 + +扫描策略(性能护栏必须有): +- 只扫描最近更新的 K 个日志文件(建议 K=20 或限定最近 N 分钟)。 +- 每个文件只读末尾 `tail_bytes`(建议 1~8MB),直接字符串搜索 `CCB_REQ_ID:` 或 `CCB_DONE:`。 +- 命中则切换到该 `log_path`,继续 tail,并写回 `.codex-session`。 + +--- + +## 8. 并发模型(如何实现“不同目录互不影响”) + +### 8.1 结构 +daemon 维护: +- `workers[session_key]`:每个 session 一个 worker(线程/协程均可) +- `queue[session_key]`:发送队列(FIFO) +- `watchers[req_id]`:每请求 watcher(由所属 session worker 驱动) + +### 8.2 并发保证 +- 不同 `session_key`:不同 worker 并行运行,互不影响。 +- 相同 `session_key`:队列串行发送(必须);watchers 可以并行等待完成。 + +### 8.3 公平性与吞吐(可选优化) +同一 session 队列可加: +- 最大排队长度; +- 请求取消; +- 超时后自动出队; +- “短请求优先”的策略(不建议默认启用,易惊喜)。 + +--- + +## 9. 安全与运维 + +### 9.1 daemon 单实例 +在 `~/.ccb/run/` 放置: +- `caskd.lock`:进程锁(记录 pid;stale 检测) +- `caskd.json`:状态文件(host/port/token/pid/version/started_at) + +权限建议:状态文件与锁文件至少 0600(仅用户可读写)。 + +### 9.2 客户端行为(`bin/cask`) +- 默认连接 daemon: + - 读 `~/.ccb/run/caskd.json` 获取连接信息; + - token 校验; +- 连接失败时: + - 若 `CCB_CASKD_AUTOSTART=1`:尝试启动 daemon(`Popen`),等待状态文件就绪; + - 否则 fallback 到直连模式(可配置禁用 fallback 以强制使用 daemon)。 + +### 9.3 观测性(强烈建议) +daemon 日志(例如 `~/.ccb/run/caskd.log`)至少记录: +- req_id、work_dir、session_key、log_path、是否 fallback、anchor/done 耗时、错误栈。 + +--- + +## 10. 详细实施步骤(一步到位也按这个任务拆解) + +### Step 0:新增公共协议模块(推荐) +新增 `lib/ccbd_protocol.py`(名字可调整),包含: +- `make_req_id()` +- `wrap_codex_prompt(message, req_id)` +- `extract_done(text, req_id)` / `strip_done(text, req_id)` +- IPC JSON schema(request/response) + +### Step 1:扩展 `lib/codex_comm.py` 支持事件级读取 +新增: +- `CodexLogReader.wait_for_event(state, timeout)`(返回 role/text/new_state) +- `CodexLogReader.try_get_event(state)`(非阻塞) + +注意: +- 现有 `wait_for_message()` 保持不破坏(兼容旧调用方)。 + +### Step 2:实现 `bin/caskd` +实现要点: +- TCP server(建议 `socketserver.ThreadingTCPServer` 或 asyncio); +- 状态文件写入; +- token 校验; +- 解析请求,路由到 `session_key` worker。 + +### Step 3:实现 session resolver 与 `.codex-session` 更新器 +新增 `lib/session_resolver.py`: +- `find_codex_session_file(work_dir)` +- `load_session_info(session_file)` +- `write_session_update(session_file, updates)` + +更新策略:只根据 req_id 锚点观测到的 log_path 更新(不要靠“最新 mtime”猜)。 + +### Step 4:实现 per-session worker(队列 + 注入 + tail + 分发) +worker 逻辑: +1) 从队列取请求 +2) `pane` 自愈定位 +3) 注入 wrapped prompt(含 req_id) +4) 在日志中等 user 锚点 → 收集 assistant → done +5) 返回结果给 RPC handler +6) 写回 `.codex-session`(log_path/session_id) + +### Step 5:改造 `bin/cask` 为 daemon client(保持 CLI 兼容) +- 参数解析不变; +- 结果写 `--output` 原子语义不变; +- 退出码不变; +- 增加 env 控制: + - `CCB_CASKD=1/0` + - `CCB_CASKD_AUTOSTART=1/0` + - `CCB_CASKD_ADDR=127.0.0.1:port`(可选) + +### Step 6:测试(建议最低保障) +不依赖真实 Codex: +- 用 fixtures 构造 JSONL 样本(含 user/assistant/done),测试 watcher 状态机与 event 抽取。 +- 单测协议模块(wrap/done/strip)。 +- 单测 fallback 扫描策略(K 与 tail_bytes 护栏生效)。 + +--- + +## 11. 一步到位上方案2:建议(避免大爆炸) + +如果你要“一次合并全部方案2”,强烈建议同时做到: +1) **保留直连 fallback**(daemon 失效时还能用;否则会成为单点故障)。 +2) **feature flag**(随时切回旧模式止血)。 +3) **严格兼容 CLI 行为**(参数/退出码/输出文件语义必须不变)。 +4) **性能护栏**(fallback 扫描限制 K、tail_bytes、时间窗;避免全盘扫描卡死)。 +5) **观测性默认开启**(至少可通过 `CCB_DEBUG` 打开 meta/log,排障成本大幅下降)。 + +--- + +## 12. 验收标准(上线前必须满足) +- 任意用户输入包含 `EXECUTION_COMPLETE`/`CCB_DONE` 等字样,不会导致死等或误判完成。 +- 两个不同目录、两个不同 Codex session 并发:互不阻塞、不串台。 +- 同一 session 并发:请求排队但不串台;每个请求都能拿到自己的 `req_id` 回复。 +- 在同一 pane 内 `codex resume` 切换 session:下一次请求能自动跟随新 log 并更新 `.codex-session`。 + +--- + +## 附:快捷用法(已实现) + +- `ccb up ...` 会在 WezTerm/iTerm2 且包含 `codex` 后端时自动拉起 `caskd`(无感)。 +- 如需禁用自动拉起:`CCB_AUTO_CASKD=0 ccb up codex` +- 手动启动/停止:`caskd` / `caskd --shutdown` diff --git a/install.ps1 b/install.ps1 index b6fb456..29ef1fc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,13 +1,82 @@ -param( +param( [Parameter(Position = 0)] [ValidateSet("install", "uninstall", "help")] [string]$Command = "help", - [string]$InstallPrefix = "$env:LOCALAPPDATA\codex-dual" + [string]$InstallPrefix = "$env:LOCALAPPDATA\codex-dual", + [switch]$Yes ) +# --- UTF-8 / BOM compatibility (Windows PowerShell 5.1) --- +# Keep this near the top so Chinese/emoji output is rendered correctly. +try { + $script:utf8NoBom = [System.Text.UTF8Encoding]::new($false) +} catch { + $script:utf8NoBom = [System.Text.Encoding]::UTF8 +} +try { $OutputEncoding = $script:utf8NoBom } catch {} +try { [Console]::OutputEncoding = $script:utf8NoBom } catch {} +try { [Console]::InputEncoding = $script:utf8NoBom } catch {} +try { chcp 65001 | Out-Null } catch {} + $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +# Constants +$script:CCB_START_MARKER = "" +$script:CCB_END_MARKER = "" + +$script:SCRIPTS_TO_LINK = @( + "ccb", + "cask", "cask-w", "caskd", "cpend", "cping", + "gask", "gask-w", "gaskd", "gpend", "gping", + "oask", "oask-w", "oaskd", "opend", "oping", + "ccb-layout" +) + +$script:CLAUDE_MARKDOWN = @( + "cask-w.md", "cpend.md", "cping.md", + "gask-w.md", "gpend.md", "gping.md", + "oask-w.md", "opend.md", "oping.md" +) + +$script:LEGACY_SCRIPTS = @( + "cast", "cast-w", "codex-ask", "codex-pending", "codex-ping", + "claude-codex-dual", "claude_codex", "claude_ai", "claude_bridge" +) + +# i18n support +function Get-CCBLang { + $lang = $env:CCB_LANG + if ($lang -in @("zh", "cn", "chinese")) { return "zh" } + if ($lang -in @("en", "english")) { return "en" } + # Auto-detect from system + try { + $culture = (Get-Culture).Name + if ($culture -like "zh*") { return "zh" } + } catch {} + return "en" +} + +$script:CCBLang = Get-CCBLang + +function Get-Msg { + param([string]$Key, [string]$Arg1 = "", [string]$Arg2 = "") + $msgs = @{ + "install_complete" = @{ en = "Installation complete"; zh = "安装完成" } + "uninstall_complete" = @{ en = "Uninstall complete"; zh = "卸载完成" } + "python_old" = @{ en = "Python version too old: $Arg1"; zh = "Python 版本过旧: $Arg1" } + "requires_python" = @{ en = "ccb requires Python 3.10+"; zh = "ccb 需要 Python 3.10+" } + "confirm_windows" = @{ en = "Continue installation in Windows? (y/N)"; zh = "确认继续在 Windows 中安装?(y/N)" } + "cancelled" = @{ en = "Installation cancelled"; zh = "安装已取消" } + "windows_warning" = @{ en = "You are installing ccb in native Windows environment"; zh = "你正在 Windows 原生环境安装 ccb" } + "same_env" = @{ en = "ccb/cask-w must run in the same environment as codex/gemini."; zh = "ccb/cask-w 必须与 codex/gemini 在同一环境运行。" } + } + if ($msgs.ContainsKey($Key)) { + return $msgs[$Key][$script:CCBLang] + } + return $Key +} + function Show-Usage { Write-Host "Usage:" Write-Host " .\install.ps1 install # Install or update" @@ -30,38 +99,90 @@ function Find-Python { function Require-Python310 { param([string]$PythonCmd) - $parts = $PythonCmd -split " " | Where-Object { $_ } - $exe = $parts[0] - $args = @() - if ($parts.Length -gt 1) { - $args = $parts[1..($parts.Length - 1)] - } + # Handle commands with arguments (e.g., "py -3") + $cmdParts = $PythonCmd -split ' ', 2 + $fileName = $cmdParts[0] + $baseArgs = if ($cmdParts.Length -gt 1) { $cmdParts[1] } else { "" } + # Use ProcessStartInfo for reliable execution across different Python installations + # (e.g., Miniconda, custom paths). The & operator can fail in some environments. try { - $version = & $exe @args -c "import sys; print('{}.{}.{}'.format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))" - } catch { - Write-Host "Failed to query Python version using: $PythonCmd" - exit 1 - } + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $fileName + # Combine base arguments with Python code arguments + if ($baseArgs) { + $psi.Arguments = "$baseArgs -c `"import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro} {v.major} {v.minor}')`"" + } else { + $psi.Arguments = "-c `"import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro} {v.major} {v.minor}')`"" + } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + $process.WaitForExit() + + $vinfo = $process.StandardOutput.ReadToEnd().Trim() + if ($process.ExitCode -ne 0 -or [string]::IsNullOrEmpty($vinfo)) { + throw $process.StandardError.ReadToEnd() + } - $verParts = ($version -split "\\.") | Where-Object { $_ } - if ($verParts.Length -lt 2) { - Write-Host "❌ Unable to parse Python version: $version" + $vparts = $vinfo -split " " + if ($vparts.Length -lt 3) { + throw "Unexpected version output: $vinfo" + } + + $version = $vparts[0] + $major = [int]$vparts[1] + $minor = [int]$vparts[2] + } catch { + Write-Host "[ERROR] Failed to query Python version using: $PythonCmd" + Write-Host " Error details: $_" exit 1 } - $major = [int]$verParts[0] - $minor = [int]$verParts[1] if (($major -ne 3) -or ($minor -lt 10)) { - Write-Host "❌ Python version too old: $version" + Write-Host "[ERROR] Python version too old: $version" Write-Host " ccb requires Python 3.10+" Write-Host " Download: https://www.python.org/downloads/" exit 1 } - Write-Host "✓ Python $version" + Write-Host "[OK] Python $version" +} + +function Confirm-BackendEnv { + if ($Yes -or $env:CCB_INSTALL_ASSUME_YES -eq "1") { return } + + if (-not [Environment]::UserInteractive) { + Write-Host "[ERROR] Non-interactive environment detected, aborting to prevent Windows/WSL mismatch." + Write-Host " If codex/gemini will run in native Windows:" + Write-Host " Re-run: powershell -ExecutionPolicy Bypass -File .\install.ps1 install -Yes" + exit 1 + } + + Write-Host "" + Write-Host "================================================================" + Write-Host "[WARNING] You are installing ccb in native Windows environment" + Write-Host "================================================================" + Write-Host "ccb/cask-w must run in the same environment as codex/gemini." + Write-Host "" + Write-Host "Please confirm: You will install and run codex/gemini in native Windows (not WSL)." + Write-Host "If you plan to run codex/gemini in WSL, exit and run in WSL:" + Write-Host " ./install.sh install" + Write-Host "================================================================" + $reply = Read-Host "Continue installation in Windows? (y/N)" + if ($reply.Trim().ToLower() -notin @("y", "yes")) { + Write-Host "Installation cancelled" + exit 1 + } } function Install-Native { + Confirm-BackendEnv + $binDir = Join-Path $InstallPrefix "bin" $pythonCmd = Find-Python @@ -93,17 +214,51 @@ function Install-Native { } } - $scripts = @("ccb", "cask", "cask-w", "cping", "cpend", "gask", "gask-w", "gping", "gpend") + function Fix-PythonShebang { + param([string]$TargetPath) + if (-not $TargetPath -or -not (Test-Path $TargetPath)) { return } + try { + $text = [System.IO.File]::ReadAllText($TargetPath, [System.Text.Encoding]::UTF8) + if ($text -match '^\#\!/usr/bin/env python3') { + $text = $text -replace '^\#\!/usr/bin/env python3', '#!/usr/bin/env python' + [System.IO.File]::WriteAllText($TargetPath, $text, $script:utf8NoBom) + } + } catch { + return + } + } + + $scripts = @( + "ccb", + "cask", "cask-w", "caskd", "cping", "cpend", + "gask", "gask-w", "gaskd", "gping", "gpend", + "oask", "oask-w", "oaskd", "oping", "opend", + "ccb-layout" + ) + + # In MSYS/Git-Bash, invoking the script file directly will honor the shebang. + # Windows typically has `python` but not `python3`, so rewrite shebangs for compatibility. + foreach ($script in $scripts) { + if ($script -eq "ccb") { + Fix-PythonShebang (Join-Path $InstallPrefix "ccb") + } else { + Fix-PythonShebang (Join-Path $InstallPrefix ("bin\\" + $script)) + } + } + foreach ($script in $scripts) { $batPath = Join-Path $binDir "$script.bat" + $cmdPath = Join-Path $binDir "$script.cmd" if ($script -eq "ccb") { $relPath = "..\\ccb" } else { - $relPath = "..\\bin\\$script" + # Script is installed alongside the wrapper under $InstallPrefix\bin + $relPath = $script } - $batContent = "@echo off`r`n$pythonCmd `"%~dp0$relPath`" %*" - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($batPath, $batContent, $utf8NoBom) + $wrapperContent = "@echo off`r`nset `"PYTHON=python`"`r`nwhere python >NUL 2>&1 || set `"PYTHON=py -3`"`r`n%PYTHON% `"%~dp0$relPath`" %*" + [System.IO.File]::WriteAllText($batPath, $wrapperContent, $script:utf8NoBom) + # .cmd wrapper for PowerShell/CMD users (and tools preferring .cmd over raw shebang scripts) + [System.IO.File]::WriteAllText($cmdPath, $wrapperContent, $script:utf8NoBom) } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") @@ -116,6 +271,58 @@ function Install-Native { Write-Host "Added $binDir to user PATH" } + # Git version injection + function Get-GitVersionInfo { + param([string]$RepoRoot) + + $commit = "" + $date = "" + + # 方法1: 本地 Git + if (Get-Command git -ErrorAction SilentlyContinue) { + if (Test-Path (Join-Path $RepoRoot ".git")) { + try { + $commit = (git -C $RepoRoot log -1 --format='%h' 2>$null) + $date = (git -C $RepoRoot log -1 --format='%cs' 2>$null) + } catch {} + } + } + + # 方法2: 环境变量 + if (-not $commit -and $env:CCB_GIT_COMMIT) { + $commit = $env:CCB_GIT_COMMIT + $date = $env:CCB_GIT_DATE + } + + # 方法3: GitHub API + if (-not $commit) { + try { + $api = "https://api.github.com/repos/bfly123/claude_code_bridge/commits/main" + $response = Invoke-RestMethod -Uri $api -TimeoutSec 5 -ErrorAction Stop + $commit = $response.sha.Substring(0,7) + $date = $response.commit.committer.date.Substring(0,10) + } catch {} + } + + return @{Commit=$commit; Date=$date} + } + + # 注入版本信息到 ccb 文件 + $verInfo = Get-GitVersionInfo -RepoRoot $repoRoot + if ($verInfo.Commit) { + $ccbPath = Join-Path $InstallPrefix "ccb" + if (Test-Path $ccbPath) { + try { + $content = Get-Content $ccbPath -Raw -Encoding UTF8 + $content = $content -replace 'GIT_COMMIT = ""', "GIT_COMMIT = `"$($verInfo.Commit)`"" + $content = $content -replace 'GIT_DATE = ""', "GIT_DATE = `"$($verInfo.Date)`"" + [System.IO.File]::WriteAllText($ccbPath, $content, [System.Text.UTF8Encoding]::new($false)) + Write-Host "Injected version info: $($verInfo.Commit) $($verInfo.Date)" + } catch { + Write-Warning "Failed to inject version info: $_" + } + } + } Install-ClaudeConfig Write-Host "" @@ -125,6 +332,8 @@ function Install-Native { Write-Host "Quick start:" Write-Host " ccb up codex # Start with Codex backend" Write-Host " ccb up gemini # Start with Gemini backend" + Write-Host " ccb up opencode # Start with OpenCode backend" + Write-Host " ccb-layout # Start 2x2 layout (Codex+Gemini+OpenCode)" } function Install-ClaudeConfig { @@ -147,50 +356,84 @@ function Install-ClaudeConfig { } } - $codexRules = @" - -## Codex Collaboration Rules -Codex is another AI assistant running via tmux or WezTerm. When user intent involves asking/consulting/collaborating with Codex: - -Fast path (minimize latency): -- If the user message starts with any of: ``@codex``, ``codex:``, ``codex:`` then immediately run: - - ``cask-w ""`` (sync, waits for reply) -- If user message is only the prefix (no content), ask a 1-line clarification for what to send. - -Trigger conditions (any match): -- User mentions codex/Codex with questioning/requesting tone -- User wants codex to do something, give advice, or help review -- User asks about codex's status or previous reply + # Install skills + $skillsDir = Join-Path $claudeDir "skills" + $srcSkills = Join-Path $repoRoot "skills" + if (Test-Path $srcSkills) { + if (-not (Test-Path $skillsDir)) { + New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null + } + Get-ChildItem -Path $srcSkills -Directory | ForEach-Object { + $destPath = Join-Path $skillsDir $_.Name + if (Test-Path $destPath) { Remove-Item -Recurse -Force $destPath } + Copy-Item -Recurse $_.FullName $destPath + Write-Host " Installed skill: $($_.Name)" + } + } -Command selection: -- Default ask/collaborate: ``cask-w ""`` (sync, waits for reply) -- Send without waiting: ``cask ""`` (async, returns immediately) -- Check connectivity: ``cping`` -- View previous reply: ``cpend`` + $codexRules = @" + +## Collaboration Rules (Codex / Gemini / OpenCode) +Codex, Gemini, and OpenCode are other AI assistants running in separate terminal sessions (WezTerm, iTerm2, or tmux). -## Gemini Collaboration Rules -Gemini is another AI assistant running via tmux or WezTerm. When user intent involves asking/consulting/collaborating with Gemini: +### Common Rules (all assistants) +Trigger (any match): +- User explicitly asks to consult one of them (e.g. "ask codex ...", "let gemini ...") +- User uses an assistant prefix (see table) +- User asks about that assistant's status (e.g. "is codex alive?") Fast path (minimize latency): -- If the user message starts with any of: ``@gemini``, ``gemini:``, ``gemini:`` then immediately run: - - ``gask-w ""`` (sync, waits for reply) -- If user message is only the prefix (no content), ask a 1-line clarification for what to send. - -Trigger conditions (any match): -- User mentions gemini/Gemini with questioning/requesting tone -- User wants gemini to do something, give advice, or help review -- User asks about gemini's status or previous reply - -Command selection: -- Default ask/collaborate: ``gask-w ""`` (sync, waits for reply) -- Send without waiting: ``gask ""`` (async, returns immediately) -- Check connectivity: ``gping`` -- View previous reply: ``gpend`` +- If the user message starts with a prefix: treat the rest as the question and dispatch immediately. +- If the user message is only the prefix (no question): ask a 1-line clarification for what to send. + +Actions: +- Ask a question (default) -> ``Bash(ASK_CMD "", run_in_background=true)``, tell user "ASSISTANT processing (task: xxx)", then END your turn +- Check connectivity -> run ``PING_CMD`` +- Use blocking/wait or "show previous reply" commands ONLY if the user explicitly requests them + +Important restrictions: +- After starting a background ask, do NOT poll for results; wait for ``bash-notification`` +- Do NOT use ``*-w`` / ``*pend`` / ``*end`` unless the user explicitly requests + +### Command Map +| Assistant | Prefixes | ASK_CMD (background) | PING_CMD | Explicit-request-only | +|---|---|---|---|---| +| Codex | ``@codex``, ``codex:``, ``ask codex``, ``let codex``, ``/cask`` | ``cask`` | ``cping`` | ``cask-w``, ``cpend`` | +| Gemini | ``@gemini``, ``gemini:``, ``ask gemini``, ``let gemini``, ``/gask`` | ``gask`` | ``gping`` | ``gask-w``, ``gpend`` | +| OpenCode | ``@opencode``, ``opencode:``, ``ask opencode``, ``let opencode``, ``/oask`` | ``oask`` | ``oping`` | ``oask-w``, ``opend`` | + +Examples: +- ``codex: review this code`` -> ``Bash(cask "...", run_in_background=true)``, END turn +- ``is gemini alive?`` -> ``gping`` + "@ if (Test-Path $claudeMd) { $content = Get-Content -Raw $claudeMd - if ($content -notlike "*Codex Collaboration Rules*") { + + if ($content -match [regex]::Escape($script:CCB_START_MARKER)) { + # Replace existing CCB config block (keep rest of file intact) + $pattern = '(?s).*?' + $newContent = [regex]::Replace($content, $pattern, $codexRules) + $newContent | Out-File -Encoding UTF8 -FilePath $claudeMd + Write-Host "Updated CLAUDE.md with collaboration rules" + } elseif ($content -match '##\s+(Codex|Gemini|OpenCode)\s+Collaboration Rules' -or $content -match '##\s+(Codex|Gemini|OpenCode)\s+协作规则') { + # Remove legacy rule blocks then append the new unified block + $patterns = @( + '(?s)## Codex Collaboration Rules.*?(?=\n## (?!Gemini)|\Z)', + '(?s)## Codex 协作规则.*?(?=\n## |\Z)', + '(?s)## Gemini Collaboration Rules.*?(?=\n## |\Z)', + '(?s)## Gemini 协作规则.*?(?=\n## |\Z)', + '(?s)## OpenCode Collaboration Rules.*?(?=\n## |\Z)', + '(?s)## OpenCode 协作规则.*?(?=\n## |\Z)' + ) + foreach ($p in $patterns) { + $content = [regex]::Replace($content, $p, '') + } + $content = ($content.TrimEnd() + "`n") + ($content + $codexRules + "`n") | Out-File -Encoding UTF8 -FilePath $claudeMd + Write-Host "Updated CLAUDE.md with collaboration rules" + } else { Add-Content -Path $claudeMd -Value $codexRules Write-Host "Updated CLAUDE.md with collaboration rules" } @@ -201,7 +444,8 @@ Command selection: $allowList = @( "Bash(cask:*)", "Bash(cask-w:*)", "Bash(cpend)", "Bash(cping)", - "Bash(gask:*)", "Bash(gask-w:*)", "Bash(gpend)", "Bash(gping)" + "Bash(gask:*)", "Bash(gask-w:*)", "Bash(gpend)", "Bash(gping)", + "Bash(oask:*)", "Bash(oask-w:*)", "Bash(opend)", "Bash(oping)" ) if (Test-Path $settingsJson) { diff --git a/install.sh b/install.sh index 0900572..e90b7ea 100755 --- a/install.sh +++ b/install.sh @@ -6,27 +6,117 @@ INSTALL_PREFIX="${CODEX_INSTALL_PREFIX:-$HOME/.local/share/codex-dual}" BIN_DIR="${CODEX_BIN_DIR:-$HOME/.local/bin}" readonly REPO_ROOT INSTALL_PREFIX BIN_DIR +# i18n support +detect_lang() { + local lang="${CCB_LANG:-auto}" + case "$lang" in + zh|cn|chinese) echo "zh" ;; + en|english) echo "en" ;; + *) + local sys_lang="${LANG:-${LC_ALL:-${LC_MESSAGES:-}}}" + if [[ "$sys_lang" == zh* ]] || [[ "$sys_lang" == *chinese* ]]; then + echo "zh" + else + echo "en" + fi + ;; + esac +} + +CCB_LANG_DETECTED="$(detect_lang)" + +# Message function +msg() { + local key="$1" + shift + local en_msg zh_msg + case "$key" in + install_complete) + en_msg="Installation complete" + zh_msg="安装完成" ;; + uninstall_complete) + en_msg="Uninstall complete" + zh_msg="卸载完成" ;; + python_version_old) + en_msg="Python version too old: $1" + zh_msg="Python 版本过旧: $1" ;; + requires_python) + en_msg="Requires Python 3.10+" + zh_msg="需要 Python 3.10+" ;; + missing_dep) + en_msg="Missing dependency: $1" + zh_msg="缺少依赖: $1" ;; + detected_env) + en_msg="Detected $1 environment" + zh_msg="检测到 $1 环境" ;; + confirm_wsl) + en_msg="Confirm continue installing in WSL? (y/N)" + zh_msg="确认继续在 WSL 中安装?(y/N)" ;; + cancelled) + en_msg="Installation cancelled" + zh_msg="安装已取消" ;; + wsl_warning) + en_msg="Detected WSL environment" + zh_msg="检测到 WSL 环境" ;; + same_env_required) + en_msg="ccb/cask-w must run in the same environment as codex/gemini." + zh_msg="ccb/cask-w 必须与 codex/gemini 在同一环境运行。" ;; + confirm_wsl_native) + en_msg="Please confirm: you will install and run codex/gemini in WSL (not Windows native)." + zh_msg="请确认:你将在 WSL 中安装并运行 codex/gemini(不是 Windows 原生)。" ;; + wezterm_recommended) + en_msg="Recommend installing WezTerm as terminal frontend" + zh_msg="推荐安装 WezTerm 作为终端前端" ;; + root_error) + en_msg="ERROR: Do not run as root/sudo. Please run as normal user." + zh_msg="错误:请勿以 root/sudo 身份运行。请使用普通用户执行。" ;; + *) + en_msg="$key" + zh_msg="$key" ;; + esac + if [[ "$CCB_LANG_DETECTED" == "zh" ]]; then + echo "$zh_msg" + else + echo "$en_msg" + fi +} + +# Check for root/sudo - refuse to run as root +if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + msg root_error >&2 + exit 1 +fi + SCRIPTS_TO_LINK=( bin/cask bin/cask-w + bin/caskd bin/cpend bin/cping bin/gask bin/gask-w + bin/gaskd bin/gpend bin/gping + bin/oask + bin/oask-w + bin/oaskd + bin/opend + bin/oping + bin/ccb-layout ccb ) CLAUDE_MARKDOWN=( - cask.md cask-w.md cpend.md cping.md - gask.md gask-w.md gpend.md gping.md + oask-w.md + opend.md + oping.md ) LEGACY_SCRIPTS=( @@ -43,14 +133,14 @@ LEGACY_SCRIPTS=( usage() { cat <<'USAGE' -用法: - ./install.sh install # 安装或更新 Codex 双窗口工具 - ./install.sh uninstall # 卸载已安装内容 - -可选环境变量: - CODEX_INSTALL_PREFIX 安装目录 (默认: ~/.local/share/codex-dual) - CODEX_BIN_DIR 可执行文件目录 (默认: ~/.local/bin) - CODEX_CLAUDE_COMMAND_DIR 自定义 Claude 命令目录 (默认自动检测) +Usage: + ./install.sh install # Install or update Codex dual-window tools + ./install.sh uninstall # Uninstall installed content + +Optional environment variables: + CODEX_INSTALL_PREFIX Install directory (default: ~/.local/share/codex-dual) + CODEX_BIN_DIR Executable directory (default: ~/.local/bin) + CODEX_CLAUDE_COMMAND_DIR Custom Claude commands directory (default: auto-detect) USAGE } @@ -82,25 +172,64 @@ require_command() { local cmd="$1" local pkg="${2:-$1}" if ! command -v "$cmd" >/dev/null 2>&1; then - echo "❌ 缺少依赖: $cmd" - echo " 请先安装 $pkg,再重新运行 install.sh" + echo "ERROR: Missing dependency: $cmd" + echo " Please install $pkg first, then re-run install.sh" exit 1 fi } +PYTHON_BIN="${CCB_PYTHON_BIN:-}" + +_python_check_310() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || return 1 + "$cmd" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1 +} + +pick_python_bin() { + if [[ -n "${PYTHON_BIN}" ]] && _python_check_310 "${PYTHON_BIN}"; then + return 0 + fi + for cmd in python3 python; do + if _python_check_310 "$cmd"; then + PYTHON_BIN="$cmd" + return 0 + fi + done + return 1 +} + +pick_any_python_bin() { + if [[ -n "${PYTHON_BIN}" ]] && command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + return 0 + fi + for cmd in python3 python; do + if command -v "$cmd" >/dev/null 2>&1; then + PYTHON_BIN="$cmd" + return 0 + fi + done + return 1 +} + require_python_version() { # ccb requires Python 3.10+ (PEP 604 type unions: `str | None`, etc.) + if ! pick_python_bin; then + echo "ERROR: Missing dependency: python (3.10+ required)" + echo " Please install Python 3.10+ and ensure it is on PATH, then re-run install.sh" + exit 1 + fi local version - version="$(python3 -c 'import sys; print("{}.{}.{}".format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))' 2>/dev/null || echo unknown)" - if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then - echo "❌ Python 版本过低: $version" - echo " 需要 Python 3.10+,请升级后重试" + version="$("$PYTHON_BIN" -c 'import sys; print("{}.{}.{}".format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))' 2>/dev/null || echo unknown)" + if ! _python_check_310 "$PYTHON_BIN"; then + echo "ERROR: Python version too old: $version" + echo " Requires Python 3.10+, please upgrade and retry" exit 1 fi - echo "✓ Python $version" + echo "OK: Python $version ($PYTHON_BIN)" } -# 根据 uname 返回 linux / macos / unknown +# Return linux / macos / unknown based on uname detect_platform() { local name name="$(uname -s 2>/dev/null || echo unknown)" @@ -127,13 +256,42 @@ check_wsl_compatibility() { if is_wsl; then local ver ver="$(get_wsl_version)" - if [[ "$ver" == "1" ]]; then - echo "❌ WSL 1 不支持 FIFO 管道,请升级到 WSL 2" - echo " 运行: wsl --set-version 2" - exit 1 - fi - echo "✅ 检测到 WSL 2 环境" + echo "OK: Detected WSL $ver environment" + fi +} + +confirm_backend_env_wsl() { + if ! is_wsl; then + return fi + + if [[ "${CCB_INSTALL_ASSUME_YES:-}" == "1" ]]; then + return + fi + + if [[ ! -t 0 ]]; then + echo "ERROR: Installing in WSL but detected non-interactive terminal; aborted to avoid env mismatch." + echo " If you confirm codex/gemini will be installed and run in WSL:" + echo " Re-run: CCB_INSTALL_ASSUME_YES=1 ./install.sh install" + exit 1 + fi + + echo + echo "================================================================" + echo "WARN: Detected WSL environment" + echo "================================================================" + echo "ccb/cask-w must run in the same environment as codex/gemini." + echo + echo "Please confirm: you will install and run codex/gemini in WSL (not Windows native)." + echo "If you plan to run codex/gemini in Windows native, exit and run on Windows side:" + echo " powershell -ExecutionPolicy Bypass -File .\\install.ps1 install" + echo "================================================================" + echo + read -r -p "Confirm continue installing in WSL? (y/N): " reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "Installation cancelled"; exit 1 ;; + esac } print_tmux_install_hint() { @@ -142,9 +300,9 @@ print_tmux_install_hint() { case "$platform" in macos) if command -v brew >/dev/null 2>&1; then - echo " macOS: 运行 'brew install tmux'" + echo " macOS: Run 'brew install tmux'" else - echo " macOS: 未检测到 Homebrew,可先安装 https://brew.sh 然后执行 'brew install tmux'" + echo " macOS: Homebrew not detected, install from https://brew.sh then run 'brew install tmux'" fi ;; linux) @@ -161,75 +319,79 @@ print_tmux_install_hint() { elif command -v zypper >/dev/null 2>&1; then echo " openSUSE: sudo zypper install -y tmux" else - echo " Linux: 请使用发行版自带的包管理器安装 tmux" + echo " Linux: Please use your distro's package manager to install tmux" fi ;; *) - echo " 请参考 https://github.com/tmux/tmux/wiki/Installing 获取 tmux 安装方法" + echo " See https://github.com/tmux/tmux/wiki/Installing for tmux installation" ;; esac } -# 检测是否在 iTerm2 环境中运行 +# Detect if running in iTerm2 environment is_iterm2_environment() { - # 检查 ITERM_SESSION_ID 环境变量 + # Check ITERM_SESSION_ID environment variable if [[ -n "${ITERM_SESSION_ID:-}" ]]; then return 0 fi - # 检查 TERM_PROGRAM + # Check TERM_PROGRAM if [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; then return 0 fi - # macOS 上检查 iTerm2 是否正在运行 + # Check if iTerm2 is running on macOS if [[ "$(uname)" == "Darwin" ]] && pgrep -x "iTerm2" >/dev/null 2>&1; then return 0 fi return 1 } -# 安装 it2 CLI +# Install it2 CLI install_it2() { echo - echo "📦 正在安装 it2 CLI..." + echo "INFO: Installing it2 CLI..." - # 检查 pip3 是否可用 - if ! command -v pip3 >/dev/null 2>&1; then - echo "❌ 未找到 pip3,无法自动安装 it2" - echo " 请手动运行: python3 -m pip install it2" + if ! pick_python_bin; then + echo "ERROR: Python 3.10+ not found, cannot auto-install it2" + echo " Please install Python 3.10+ and retry" + return 1 + fi + if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then + echo "ERROR: pip not found for ${PYTHON_BIN}, cannot auto-install it2" + echo " Please run manually: ${PYTHON_BIN} -m pip install it2" return 1 fi - # 安装 it2 - if pip3 install it2 --user 2>&1; then - echo "✅ it2 CLI 安装成功" + # Install it2 + if "$PYTHON_BIN" -m pip install it2 --user 2>&1; then + echo "OK: it2 CLI installed successfully" - # 检查是否在 PATH 中 + # Check if in PATH if ! command -v it2 >/dev/null 2>&1; then local user_bin - user_bin="$(python3 -m site --user-base)/bin" + user_bin="$("$PYTHON_BIN" -m site --user-base)/bin" echo - echo "⚠️ it2 可能不在 PATH 中,请添加以下路径到你的 shell 配置文件:" + echo "WARN: it2 may not be in PATH, please add the following to your shell config:" echo " export PATH=\"$user_bin:\$PATH\"" fi return 0 else - echo "❌ it2 安装失败" + echo "ERROR: it2 installation failed" return 1 fi } -# 显示 iTerm2 Python API 启用提示 +# Show iTerm2 Python API enable reminder show_iterm2_api_reminder() { echo echo "================================================================" - echo "🔔 重要提示:请在 iTerm2 中启用 Python API" + echo "IMPORTANT: Please enable Python API in iTerm2" echo "================================================================" - echo " 步骤:" - echo " 1. 打开 iTerm2" - echo " 2. 进入 Preferences (⌘ + ,)" - echo " 3. 选择 Magic 标签页" - echo " 4. 勾选 \"Enable Python API\"" - echo " 5. 确认警告对话框" + echo " Steps:" + echo " 1. Open iTerm2" + echo " 2. Go to Preferences (Cmd + ,)" + echo " 3. Select Magic tab" + echo " 4. Check \"Enable Python API\"" + echo " 5. Confirm the warning dialog" echo "================================================================" echo } @@ -238,34 +400,34 @@ require_terminal_backend() { local wezterm_override="${CODEX_WEZTERM_BIN:-${WEZTERM_BIN:-}}" # ============================================ - # 优先检测当前运行环境,确保使用正确的终端工具 + # Prioritize detecting current environment # ============================================ - # 1. 如果在 WezTerm 环境中运行 + # 1. If running in WezTerm environment if [[ -n "${WEZTERM_PANE:-}" ]]; then if [[ -n "${wezterm_override}" ]] && { command -v "${wezterm_override}" >/dev/null 2>&1 || [[ -f "${wezterm_override}" ]]; }; then - echo "✓ 检测到 WezTerm 环境 (${wezterm_override})" + echo "OK: Detected WezTerm environment (${wezterm_override})" return fi if command -v wezterm >/dev/null 2>&1 || command -v wezterm.exe >/dev/null 2>&1; then - echo "✓ 检测到 WezTerm 环境" + echo "OK: Detected WezTerm environment" return fi fi - # 2. 如果在 iTerm2 环境中运行 + # 2. If running in iTerm2 environment if is_iterm2_environment; then - # 检查是否已安装 it2 + # Check if it2 is installed if command -v it2 >/dev/null 2>&1; then - echo "✓ 检测到 iTerm2 环境 (it2 CLI 已安装)" - echo " 💡 请确保已启用 iTerm2 Python API (Preferences > Magic > Enable Python API)" + echo "OK: Detected iTerm2 environment (it2 CLI installed)" + echo " NOTE: Please ensure iTerm2 Python API is enabled (Preferences > Magic > Enable Python API)" return fi - # 未安装 it2,询问是否安装 - echo "🍎 检测到 iTerm2 环境,但未安装 it2 CLI" + # it2 not installed, ask to install + echo "INFO: Detected iTerm2 environment but it2 CLI not installed" echo - read -p "是否自动安装 it2 CLI?(Y/n): " -n 1 -r + read -p "Auto-install it2 CLI? (Y/n): " -n 1 -r echo if [[ ! $REPLY =~ ^[Nn]$ ]]; then @@ -274,68 +436,68 @@ require_terminal_backend() { return fi else - echo "跳过 it2 安装,将使用 tmux 作为后备方案" + echo "Skipping it2 installation, will use tmux as fallback" fi fi - # 3. 如果在 tmux 环境中运行 + # 3. If running in tmux environment if [[ -n "${TMUX:-}" ]]; then - echo "✓ 检测到 tmux 环境" + echo "OK: Detected tmux environment" return fi # ============================================ - # 不在特定环境中,按可用性检测 + # Not in specific environment, detect by availability # ============================================ - # 4. 检查 WezTerm 环境变量覆盖 + # 4. Check WezTerm environment variable override if [[ -n "${wezterm_override}" ]]; then if command -v "${wezterm_override}" >/dev/null 2>&1 || [[ -f "${wezterm_override}" ]]; then - echo "✓ 检测到 WezTerm (${wezterm_override})" + echo "OK: Detected WezTerm (${wezterm_override})" return fi fi - # 5. 检查 WezTerm 命令 + # 5. Check WezTerm command if command -v wezterm >/dev/null 2>&1 || command -v wezterm.exe >/dev/null 2>&1; then - echo "✓ 检测到 WezTerm" + echo "OK: Detected WezTerm" return fi - # WSL 场景:Windows PATH 可能未注入 WSL,尝试常见安装路径 + # WSL: Windows PATH may not be injected, try common install paths if [[ -f "/proc/version" ]] && grep -qi microsoft /proc/version 2>/dev/null; then if [[ -x "/mnt/c/Program Files/WezTerm/wezterm.exe" ]] || [[ -f "/mnt/c/Program Files/WezTerm/wezterm.exe" ]]; then - echo "✓ 检测到 WezTerm (/mnt/c/Program Files/WezTerm/wezterm.exe)" + echo "OK: Detected WezTerm (/mnt/c/Program Files/WezTerm/wezterm.exe)" return fi if [[ -x "/mnt/c/Program Files (x86)/WezTerm/wezterm.exe" ]] || [[ -f "/mnt/c/Program Files (x86)/WezTerm/wezterm.exe" ]]; then - echo "✓ 检测到 WezTerm (/mnt/c/Program Files (x86)/WezTerm/wezterm.exe)" + echo "OK: Detected WezTerm (/mnt/c/Program Files (x86)/WezTerm/wezterm.exe)" return fi fi - # 6. 检查 it2 CLI + # 6. Check it2 CLI if command -v it2 >/dev/null 2>&1; then - echo "✓ 检测到 it2 CLI" + echo "OK: Detected it2 CLI" return fi - # 7. 检查 tmux + # 7. Check tmux if command -v tmux >/dev/null 2>&1; then - echo "✓ 检测到 tmux(建议同时安装 WezTerm 以获得更好体验)" + echo "OK: Detected tmux (recommend also installing WezTerm for better experience)" return fi - # 8. 没有找到任何可用的终端复用器 - echo "❌ 缺少依赖: WezTerm、tmux 或 it2 (至少需要安装其中一个)" - echo " WezTerm 官网: https://wezfurlong.org/wezterm/" + # 8. No terminal multiplexer found + echo "ERROR: Missing dependency: WezTerm, tmux or it2 (at least one required)" + echo " WezTerm website: https://wezfurlong.org/wezterm/" - # macOS 上额外提示 iTerm2 + it2 选项 + # Extra hint for macOS users about iTerm2 + it2 if [[ "$(uname)" == "Darwin" ]]; then echo - echo "💡 macOS 用户推荐选项:" - echo " - 如果你使用 iTerm2,可以安装 it2 CLI: pip3 install it2" - echo " - 或者安装 tmux: brew install tmux" + echo "NOTE: macOS user recommended options:" + echo " - If using iTerm2, install it2 CLI: pip3 install it2" + echo " - Or install tmux: brew install tmux" fi print_tmux_install_hint @@ -382,9 +544,10 @@ save_wezterm_config() { local wezterm_path wezterm_path="$(detect_wezterm_path)" if [[ -n "$wezterm_path" ]]; then - mkdir -p "$HOME/.config/ccb" - echo "CODEX_WEZTERM_BIN=${wezterm_path}" > "$HOME/.config/ccb/env" - echo "✓ WezTerm 路径已缓存: $wezterm_path" + local cfg_root="${XDG_CONFIG_HOME:-$HOME/.config}" + mkdir -p "$cfg_root/ccb" + echo "CODEX_WEZTERM_BIN=${wezterm_path}" > "$cfg_root/ccb/env" + echo "OK: WezTerm path cached: $wezterm_path" fi } @@ -415,6 +578,37 @@ copy_project() { mkdir -p "$(dirname "$INSTALL_PREFIX")" mv "$staging" "$INSTALL_PREFIX" trap - EXIT + + # Update GIT_COMMIT and GIT_DATE in ccb file + local git_commit="" git_date="" + + # Method 1: From git repo + if command -v git >/dev/null 2>&1 && [[ -d "$REPO_ROOT/.git" ]]; then + git_commit=$(git -C "$REPO_ROOT" log -1 --format='%h' 2>/dev/null || echo "") + git_date=$(git -C "$REPO_ROOT" log -1 --format='%cs' 2>/dev/null || echo "") + fi + + # Method 2: From environment variables (set by ccb update) + if [[ -z "$git_commit" && -n "${CCB_GIT_COMMIT:-}" ]]; then + git_commit="$CCB_GIT_COMMIT" + git_date="${CCB_GIT_DATE:-}" + fi + + # Method 3: From GitHub API (fallback) + if [[ -z "$git_commit" ]] && command -v curl >/dev/null 2>&1; then + local api_response + api_response=$(curl -fsSL "https://api.github.com/repos/bfly123/claude_code_bridge/commits/main" 2>/dev/null || echo "") + if [[ -n "$api_response" ]]; then + git_commit=$(echo "$api_response" | grep -o '"sha": "[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-7) + git_date=$(echo "$api_response" | grep -o '"date": "[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-10) + fi + fi + + if [[ -n "$git_commit" && -f "$INSTALL_PREFIX/ccb" ]]; then + sed -i.bak "s/^GIT_COMMIT = .*/GIT_COMMIT = \"$git_commit\"/" "$INSTALL_PREFIX/ccb" + sed -i.bak "s/^GIT_DATE = .*/GIT_DATE = \"$git_date\"/" "$INSTALL_PREFIX/ccb" + rm -f "$INSTALL_PREFIX/ccb.bak" + fi } install_bin_links() { @@ -424,7 +618,7 @@ install_bin_links() { local name name="$(basename "$path")" if [[ ! -f "$INSTALL_PREFIX/$path" ]]; then - echo "⚠️ 未找到脚本 $INSTALL_PREFIX/$path,跳过创建链接" + echo "WARN: Script not found $INSTALL_PREFIX/$path, skipping link creation" continue fi chmod +x "$INSTALL_PREFIX/$path" @@ -441,7 +635,45 @@ install_bin_links() { rm -f "$BIN_DIR/$legacy" done - echo "已在 $BIN_DIR 创建可执行入口" + echo "Created executable links in $BIN_DIR" +} + +ensure_path_configured() { + # Check if BIN_DIR is already in PATH + if [[ ":$PATH:" == *":$BIN_DIR:"* ]]; then + return + fi + + local shell_rc="" + local current_shell + current_shell="$(basename "${SHELL:-/bin/bash}")" + + case "$current_shell" in + zsh) shell_rc="$HOME/.zshrc" ;; + bash) + if [[ -f "$HOME/.bash_profile" ]]; then + shell_rc="$HOME/.bash_profile" + else + shell_rc="$HOME/.bashrc" + fi + ;; + *) shell_rc="$HOME/.profile" ;; + esac + + local path_line="export PATH=\"\$HOME/.local/bin:\$PATH\"" + + # Check if already configured in shell rc + if [[ -f "$shell_rc" ]] && grep -qF '.local/bin' "$shell_rc" 2>/dev/null; then + echo "PATH already configured in $shell_rc (restart terminal to apply)" + return + fi + + # Add to shell rc + echo "" >> "$shell_rc" + echo "# Added by ccb installer" >> "$shell_rc" + echo "$path_line" >> "$shell_rc" + echo "OK: Added $BIN_DIR to PATH in $shell_rc" + echo " Run: source $shell_rc (or restart terminal)" } install_claude_commands() { @@ -454,10 +686,51 @@ install_claude_commands() { chmod 0644 "$claude_dir/$doc" 2>/dev/null || true done - echo "已更新 Claude 命令目录: $claude_dir" + echo "Updated Claude commands directory: $claude_dir" } -RULE_MARKER="## Codex Collaboration Rules" +install_claude_skills() { + local skills_src="$REPO_ROOT/skills" + local skills_dst="$HOME/.claude/skills" + + if [[ ! -d "$skills_src" ]]; then + return + fi + + mkdir -p "$skills_dst" + for skill_dir in "$skills_src"/*/; do + [[ -d "$skill_dir" ]] || continue + local skill_name + skill_name=$(basename "$skill_dir") + rm -rf "$skills_dst/$skill_name" + cp -r "$skill_dir" "$skills_dst/$skill_name" + echo " Installed skill: $skill_name" + done + echo "Updated Claude skills directory: $skills_dst" +} + +install_codex_skills() { + local skills_src="$REPO_ROOT/codex_skills" + local skills_dst="${CODEX_HOME:-$HOME/.codex}/skills" + + if [[ ! -d "$skills_src" ]]; then + return + fi + + mkdir -p "$skills_dst" + for skill_dir in "$skills_src"/*/; do + [[ -d "$skill_dir" ]] || continue + local skill_name + skill_name=$(basename "$skill_dir") + rm -rf "$skills_dst/$skill_name" + cp -r "$skill_dir" "$skills_dst/$skill_name" + echo " Installed Codex skill: $skill_name" + done + echo "Updated Codex skills directory: $skills_dst" +} + +CCB_START_MARKER="" +CCB_END_MARKER="" LEGACY_RULE_MARKER="## Codex 协作规则" remove_codex_mcp() { @@ -467,136 +740,168 @@ remove_codex_mcp() { return fi - if ! command -v python3 >/dev/null 2>&1; then - echo "⚠️ 需要 python3 来检测 MCP 配置" + if ! pick_python_bin; then + echo "WARN: python required to detect MCP configuration" return fi local has_codex_mcp - has_codex_mcp=$(python3 -c " + has_codex_mcp=$("$PYTHON_BIN" -c " import json + try: - with open('$claude_config', 'r') as f: + with open('$claude_config', 'r', encoding='utf-8') as f: data = json.load(f) + projects = data.get('projects', {}) if isinstance(data, dict) else {} found = False - for proj, cfg in data.get('projects', {}).items(): - servers = cfg.get('mcpServers', {}) - for name in list(servers.keys()): - if 'codex' in name.lower(): - found = True + if isinstance(projects, dict): + for _proj, cfg in projects.items(): + if not isinstance(cfg, dict): + continue + servers = cfg.get('mcpServers', {}) + if not isinstance(servers, dict): + continue + for name in list(servers.keys()): + if 'codex' in str(name).lower(): + found = True + break + if found: break - if found: - break print('yes' if found else 'no') -except: +except Exception: print('no') " 2>/dev/null) if [[ "$has_codex_mcp" == "yes" ]]; then - echo "⚠️ 检测到 codex 相关的 MCP 配置,正在移除以避免冲突..." - python3 -c " + echo "WARN: Detected codex-related MCP configuration, removing to avoid conflicts..." + "$PYTHON_BIN" -c " import json -with open('$claude_config', 'r') as f: - data = json.load(f) -removed = [] -for proj, cfg in data.get('projects', {}).items(): - servers = cfg.get('mcpServers', {}) - for name in list(servers.keys()): - if 'codex' in name.lower(): - del servers[name] - removed.append(f'{proj}: {name}') -with open('$claude_config', 'w') as f: - json.dump(data, f, indent=2) -if removed: - print('已移除以下 MCP 配置:') - for r in removed: - print(f' - {r}') +import sys + +try: + with open('$claude_config', 'r', encoding='utf-8') as f: + data = json.load(f) + removed = [] + projects = data.get('projects', {}) if isinstance(data, dict) else {} + if isinstance(projects, dict): + for proj, cfg in projects.items(): + if not isinstance(cfg, dict): + continue + servers = cfg.get('mcpServers') + if not isinstance(servers, dict): + continue + for name in list(servers.keys()): + if 'codex' in str(name).lower(): + del servers[name] + removed.append(f'{proj}: {name}') + with open('$claude_config', 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + if removed: + print('Removed the following MCP configurations:') + for r in removed: + print(f' - {r}') +except Exception as e: + sys.stderr.write(f'WARN: failed cleaning MCP config: {e}\\n') + sys.exit(0) " - echo "✅ Codex MCP 配置已清理" + echo "OK: Codex MCP configuration cleaned" fi } install_claude_md_config() { local claude_md="$HOME/.claude/CLAUDE.md" mkdir -p "$HOME/.claude" + if ! pick_python_bin; then + echo "ERROR: python required to update CLAUDE.md" + return 1 + fi + + # Use temp file to avoid Bash 3.2 heredoc parsing bug with single quotes + local ccb_tmpfile="" + ccb_tmpfile="$(mktemp)" || { echo "Failed to create temp file"; return 1; } + trap 'rm -f "${ccb_tmpfile:-}"' RETURN + cat > "$ccb_tmpfile" << 'AI_RULES' + +## Collaboration Rules (Codex / Gemini / OpenCode) +Codex, Gemini, and OpenCode are other AI assistants running in separate terminal sessions (WezTerm, iTerm2, or tmux). + +### Common Rules (all assistants) +Trigger (any match): +- User explicitly asks to consult one of them (e.g. "ask codex ...", "let gemini ...") +- User uses an assistant prefix (see table) +- User asks about that assistant's status (e.g. "is codex alive?") + +Fast path (minimize latency): +- If the user message starts with a prefix: treat the rest as the question and dispatch immediately. +- If the user message is only the prefix (no question): ask a 1-line clarification for what to send. + +Actions: +- Ask a question (default) -> `Bash(ASK_CMD "", run_in_background=true)`, tell user "`ASSISTANT` processing (task: xxx)", then END your turn +- Check connectivity -> run `PING_CMD` +- Use blocking/wait or "show previous reply" commands ONLY if the user explicitly requests them + +Important restrictions: +- After starting a background ask, do NOT poll for results; wait for `bash-notification` +- Do NOT use `*-w` / `*pend` / `*end` unless the user explicitly requests + +### Command Map +| Assistant | Prefixes | ASK_CMD (background) | PING_CMD | Explicit-request-only | +|---|---|---|---|---| +| Codex | `@codex`, `codex:`, `ask codex`, `let codex`, `/cask` | `cask` | `cping` | `cask-w`, `cpend` | +| Gemini | `@gemini`, `gemini:`, `ask gemini`, `let gemini`, `/gask` | `gask` | `gping` | `gask-w`, `gpend` | +| OpenCode | `@opencode`, `opencode:`, `ask opencode`, `let opencode`, `/oask` | `oask` | `oping` | `oask-w`, `opend` | + +Examples: +- `codex: review this code` -> `Bash(cask "...", run_in_background=true)`, END turn +- `is gemini alive?` -> `gping` + +AI_RULES + local ccb_content + ccb_content="$(cat "$ccb_tmpfile")" - # Remove old rules (both legacy Chinese and new English versions) if [[ -f "$claude_md" ]]; then - if grep -qE "$RULE_MARKER|$LEGACY_RULE_MARKER|## Gemini" "$claude_md" 2>/dev/null; then - echo "Removing old collaboration rules..." - python3 -c " + if grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then + echo "Updating existing CCB config block..." + "$PYTHON_BIN" -c " +import re + +with open('$claude_md', 'r', encoding='utf-8') as f: + content = f.read() +pattern = r'.*?' +new_block = '''$ccb_content''' +content = re.sub(pattern, new_block, content, flags=re.DOTALL) +with open('$claude_md', 'w', encoding='utf-8') as f: + f.write(content) +" + elif grep -qE "$LEGACY_RULE_MARKER|## Codex Collaboration Rules|## Gemini|## OpenCode" "$claude_md" 2>/dev/null; then + echo "Removing legacy rules and adding new CCB config block..." + "$PYTHON_BIN" -c " import re + with open('$claude_md', 'r', encoding='utf-8') as f: content = f.read() -# Remove all collaboration rule sections patterns = [ - r'## Codex Collaboration Rules.*?(?=\n## |\Z)', - r'## Codex 协作规则.*?(?=\n## |\Z)', - r'## Gemini Collaboration Rules.*?(?=\n## |\Z)', - r'## Gemini 协作规则.*?(?=\n## |\Z)', + r'## Codex Collaboration Rules.*?(?=\\n## (?!Gemini)|\\Z)', + r'## Codex 协作规则.*?(?=\\n## |\\Z)', + r'## Gemini Collaboration Rules.*?(?=\\n## |\\Z)', + r'## Gemini 协作规则.*?(?=\\n## |\\Z)', + r'## OpenCode Collaboration Rules.*?(?=\\n## |\\Z)', + r'## OpenCode 协作规则.*?(?=\\n## |\\Z)', ] for p in patterns: content = re.sub(p, '', content, flags=re.DOTALL) -content = content.rstrip() + '\n' +content = content.rstrip() + '\\n' with open('$claude_md', 'w', encoding='utf-8') as f: f.write(content) " + echo "$ccb_content" >> "$claude_md" + else + echo "$ccb_content" >> "$claude_md" fi + else + echo "$ccb_content" > "$claude_md" fi - cat >> "$claude_md" << 'AI_RULES' - -## Codex Collaboration Rules -Codex is another AI assistant running in a separate terminal session (WezTerm, iTerm2 or tmux). When user intent involves asking/consulting/collaborating with Codex: - -Fast path (minimize latency): -- If the user message starts with any of: `调取codex`, `@codex`, `codex:`, `codex:`, `问codex`, `让codex` then immediately run: - - `cask-w ""` (no extra analysis, no pre-checks like cping) -- If user message is only the prefix (no content), ask a 1-line clarification for what to send. - -Trigger conditions (any match): -- User mentions codex/Codex with questioning/requesting tone -- User wants codex to do something, give advice, or help review -- User asks about codex's status or previous reply - -Command selection: -- Default ask/collaborate → `cask-w ""` (sync, waits for reply) -- Send without waiting → `cask ""` (async, returns immediately) -- Check connectivity → `cping` -- View previous reply → `cpend` - -Examples: -- "what does codex think" → cask-w -- "ask codex to review this" → cask-w -- "is codex alive" → cping -- "don't wait for reply" → cask - -## Gemini Collaboration Rules -Gemini is another AI assistant running in a separate terminal session (WezTerm, iTerm2 or tmux). When user intent involves asking/consulting/collaborating with Gemini: - -Fast path (minimize latency): -- If the user message starts with any of: `调取gemini`, `@gemini`, `gemini:`, `gemini:`, `问gemini`, `让gemini` then immediately run: - - `gask-w ""` (no extra analysis, no pre-checks like gping) -- If user message is only the prefix (no content), ask a 1-line clarification for what to send. - -Trigger conditions (any match): -- User mentions gemini/Gemini with questioning/requesting tone -- User wants gemini to do something, give advice, or help review -- User asks about gemini's status or previous reply - -Command selection: -- Default ask/collaborate → `gask-w ""` (sync, waits for reply) -- Send without waiting → `gask ""` (async, returns immediately) -- Check connectivity → `gping` -- View previous reply → `gpend` - -Examples: -- "what does gemini think" → gask-w -- "ask gemini to review this" → gask-w -- "is gemini alive" → gping -- "don't wait for reply" → gask -AI_RULES - echo "Updated AI collaboration rules in $claude_md" } @@ -613,6 +918,10 @@ install_settings_permissions() { 'Bash(gask-w:*)' 'Bash(gpend)' 'Bash(gping)' + 'Bash(oask:*)' + 'Bash(oask-w:*)' + 'Bash(opend)' + 'Bash(oping)' ) if [[ ! -f "$settings_file" ]]; then @@ -627,7 +936,11 @@ install_settings_permissions() { "Bash(gask:*)", "Bash(gask-w:*)", "Bash(gpend)", - "Bash(gping)" + "Bash(gping)", + "Bash(oask:*)", + "Bash(oask-w:*)", + "Bash(opend)", + "Bash(oping)" ], "deny": [] } @@ -640,19 +953,33 @@ SETTINGS local added=0 for perm in "${perms_to_add[@]}"; do if ! grep -q "$perm" "$settings_file" 2>/dev/null; then - if command -v python3 >/dev/null 2>&1; then - python3 -c " -import json, sys -with open('$settings_file', 'r') as f: - data = json.load(f) -if 'permissions' not in data: - data['permissions'] = {'allow': [], 'deny': []} -if 'allow' not in data['permissions']: - data['permissions']['allow'] = [] -if '$perm' not in data['permissions']['allow']: - data['permissions']['allow'].append('$perm') -with open('$settings_file', 'w') as f: - json.dump(data, f, indent=2) + if pick_python_bin; then + "$PYTHON_BIN" -c " +import json +import sys + +path = '$settings_file' +perm = '$perm' +try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict): + data = {} + perms = data.get('permissions') + if not isinstance(perms, dict): + perms = {'allow': [], 'deny': []} + data['permissions'] = perms + allow = perms.get('allow') + if not isinstance(allow, list): + allow = [] + perms['allow'] = allow + if perm not in allow: + allow.append(perm) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) +except Exception as e: + sys.stderr.write(f'WARN: failed updating {path}: {e}\\n') + sys.exit(0) " added=1 fi @@ -660,23 +987,23 @@ with open('$settings_file', 'w') as f: done if [[ $added -eq 1 ]]; then - echo "已更新 $settings_file 权限配置" + echo "Updated $settings_file permissions" else - echo "权限配置已存在于 $settings_file" + echo "Permissions already exist in $settings_file" fi } install_requirements() { check_wsl_compatibility - require_command python3 python3 + confirm_backend_env_wsl require_python_version require_terminal_backend if ! has_wezterm; then echo echo "================================================================" - echo "⚠️ 建议安装 WezTerm 作为终端前端(体验更好,推荐 WSL2/Windows 用户)" - echo " - 官网: https://wezfurlong.org/wezterm/" - echo " - 优势: 更顺滑的分屏/滚动/字体渲染,WezTerm 模式下桥接更稳定" + echo "NOTE: Recommend installing WezTerm as terminal frontend (better experience, recommended for WSL2/Windows)" + echo " - Website: https://wezfurlong.org/wezterm/" + echo " - Benefits: Smoother split/scroll/font rendering, more stable bridging in WezTerm mode" echo "================================================================" echo fi @@ -688,15 +1015,18 @@ install_all() { save_wezterm_config copy_project install_bin_links + ensure_path_configured install_claude_commands + install_claude_skills + install_codex_skills install_claude_md_config install_settings_permissions - echo "✅ 安装完成" - echo " 项目目录 : $INSTALL_PREFIX" - echo " 可执行目录: $BIN_DIR" - echo " Claude 命令已更新" - echo " 全局 CLAUDE.md 已配置 Codex 协作规则" - echo " 全局 settings.json 已添加权限" + echo "OK: Installation complete" + echo " Project dir : $INSTALL_PREFIX" + echo " Executable dir : $BIN_DIR" + echo " Claude commands updated" + echo " Global CLAUDE.md configured with Codex/Gemini/OpenCode collaboration rules" + echo " Global settings.json permissions added" } uninstall_claude_md_config() { @@ -706,29 +1036,49 @@ uninstall_claude_md_config() { return fi - if grep -qE "$RULE_MARKER|$LEGACY_RULE_MARKER|## Gemini" "$claude_md" 2>/dev/null; then - echo "正在移除 CLAUDE.md 中的协作规则..." - if command -v python3 >/dev/null 2>&1; then - python3 -c " + if grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then + echo "Removing CCB config block from CLAUDE.md..." + if pick_any_python_bin; then + "$PYTHON_BIN" -c " import re + +with open('$claude_md', 'r', encoding='utf-8') as f: + content = f.read() +pattern = r'\\n?.*?\\n?' +content = re.sub(pattern, '\\n', content, flags=re.DOTALL) +content = content.strip() + '\\n' +with open('$claude_md', 'w', encoding='utf-8') as f: + f.write(content) +" + echo "Removed CCB config from CLAUDE.md" + else + echo "WARN: python required to clean CLAUDE.md, please manually remove CCB_CONFIG block" + fi + elif grep -qE "$LEGACY_RULE_MARKER|## Codex Collaboration Rules|## Gemini|## OpenCode" "$claude_md" 2>/dev/null; then + echo "Removing legacy collaboration rules from CLAUDE.md..." + if pick_any_python_bin; then + "$PYTHON_BIN" -c " +import re + with open('$claude_md', 'r', encoding='utf-8') as f: content = f.read() -# Remove all collaboration rule sections patterns = [ - r'## Codex Collaboration Rules.*?(?=\n## |\Z)', - r'## Codex 协作规则.*?(?=\n## |\Z)', - r'## Gemini Collaboration Rules.*?(?=\n## |\Z)', - r'## Gemini 协作规则.*?(?=\n## |\Z)', + r'## Codex Collaboration Rules.*?(?=\\n## (?!Gemini)|\\Z)', + r'## Codex 协作规则.*?(?=\\n## |\\Z)', + r'## Gemini Collaboration Rules.*?(?=\\n## |\\Z)', + r'## Gemini 协作规则.*?(?=\\n## |\\Z)', + r'## OpenCode Collaboration Rules.*?(?=\\n## |\\Z)', + r'## OpenCode 协作规则.*?(?=\\n## |\\Z)', ] for p in patterns: content = re.sub(p, '', content, flags=re.DOTALL) -content = content.rstrip() + '\n' +content = content.rstrip() + '\\n' with open('$claude_md', 'w', encoding='utf-8') as f: f.write(content) " - echo "已移除 CLAUDE.md 中的协作规则" + echo "Removed collaboration rules from CLAUDE.md" else - echo "⚠️ 需要 python3 来清理 CLAUDE.md,请手动移除协作规则部分" + echo "WARN: python required to clean CLAUDE.md, please manually remove collaboration rules" fi fi } @@ -749,9 +1099,13 @@ uninstall_settings_permissions() { 'Bash(gask-w:*)' 'Bash(gpend)' 'Bash(gping)' + 'Bash(oask:*)' + 'Bash(oask-w:*)' + 'Bash(opend)' + 'Bash(oping)' ) - if command -v python3 >/dev/null 2>&1; then + if pick_any_python_bin; then local has_perms=0 for perm in "${perms_to_remove[@]}"; do if grep -q "$perm" "$settings_file" 2>/dev/null; then @@ -761,9 +1115,12 @@ uninstall_settings_permissions() { done if [[ $has_perms -eq 1 ]]; then - echo "正在移除 settings.json 中的权限配置..." - python3 -c " + echo "Removing permission configuration from settings.json..." + "$PYTHON_BIN" -c " import json +import sys + +path = '$settings_file' perms_to_remove = [ 'Bash(cask:*)', 'Bash(cask-w:*)', @@ -773,34 +1130,45 @@ perms_to_remove = [ 'Bash(gask-w:*)', 'Bash(gpend)', 'Bash(gping)', + 'Bash(oask:*)', + 'Bash(oask-w:*)', + 'Bash(opend)', + 'Bash(oping)', ] -with open('$settings_file', 'r') as f: - data = json.load(f) -if 'permissions' in data and 'allow' in data['permissions']: - data['permissions']['allow'] = [ - p for p in data['permissions']['allow'] - if p not in perms_to_remove - ] -with open('$settings_file', 'w') as f: - json.dump(data, f, indent=2) +try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict): + sys.exit(0) + perms = data.get('permissions') + if not isinstance(perms, dict): + sys.exit(0) + allow = perms.get('allow') + if not isinstance(allow, list): + sys.exit(0) + perms['allow'] = [p for p in allow if p not in perms_to_remove] + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) +except Exception: + sys.exit(0) " - echo "已移除 settings.json 中的权限配置" + echo "Removed permission configuration from settings.json" fi else - echo "⚠️ 需要 python3 来清理 settings.json,请手动移除相关权限" + echo "WARN: python required to clean settings.json, please manually remove related permissions" fi } uninstall_all() { - echo "🧹 开始卸载 ccb..." + echo "INFO: Starting ccb uninstall..." - # 1. 移除项目目录 + # 1. Remove project directory if [[ -d "$INSTALL_PREFIX" ]]; then rm -rf "$INSTALL_PREFIX" - echo "已移除项目目录: $INSTALL_PREFIX" + echo "Removed project directory: $INSTALL_PREFIX" fi - # 2. 移除 bin 链接 + # 2. Remove bin links for path in "${SCRIPTS_TO_LINK[@]}"; do local name name="$(basename "$path")" @@ -811,24 +1179,31 @@ uninstall_all() { for legacy in "${LEGACY_SCRIPTS[@]}"; do rm -f "$BIN_DIR/$legacy" done - echo "已移除 bin 链接: $BIN_DIR" + echo "Removed bin links: $BIN_DIR" - # 3. 移除 Claude 命令文件 - local claude_dir - claude_dir="$(detect_claude_dir)" - for doc in "${CLAUDE_MARKDOWN[@]}"; do - rm -f "$claude_dir/$doc" + # 3. Remove Claude command files (clean all possible locations) + local cmd_dirs=( + "$HOME/.claude/commands" + "$HOME/.config/claude/commands" + "$HOME/.local/share/claude/commands" + ) + for dir in "${cmd_dirs[@]}"; do + if [[ -d "$dir" ]]; then + for doc in "${CLAUDE_MARKDOWN[@]}"; do + rm -f "$dir/$doc" + done + echo "Cleaned commands directory: $dir" + fi done - echo "已移除 Claude 命令: $claude_dir" - # 4. 移除 CLAUDE.md 中的协作规则 + # 4. Remove collaboration rules from CLAUDE.md uninstall_claude_md_config - # 5. 移除 settings.json 中的权限配置 + # 5. Remove permission configuration from settings.json uninstall_settings_permissions - echo "✅ 卸载完成" - echo " 💡 注意: 依赖项 (python3, tmux, wezterm, it2) 未被移除" + echo "OK: Uninstall complete" + echo " NOTE: Dependencies (python, tmux, wezterm, it2) were not removed" } main() { diff --git a/lib/askd_client.py b/lib/askd_client.py new file mode 100644 index 0000000..f825f58 --- /dev/null +++ b/lib/askd_client.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional, Tuple + +from env_utils import env_bool +from providers import ProviderClientSpec +from session_utils import find_project_session_file + + +def autostart_enabled(primary_env: str, legacy_env: str, default: bool = True) -> bool: + if primary_env in os.environ: + return env_bool(primary_env, default) + if legacy_env in os.environ: + return env_bool(legacy_env, default) + return default + + +def state_file_from_env(env_name: str) -> Optional[Path]: + raw = (os.environ.get(env_name) or "").strip() + if not raw: + return None + try: + return Path(raw).expanduser() + except Exception: + return None + + +def try_daemon_request(spec: ProviderClientSpec, work_dir: Path, message: str, timeout: float, quiet: bool, state_file: Optional[Path] = None) -> Optional[Tuple[str, int]]: + if not env_bool(spec.enabled_env, True): + return None + + if not find_project_session_file(work_dir, spec.session_filename): + return None + + from importlib import import_module + daemon_module = import_module(spec.daemon_module) + read_state = getattr(daemon_module, "read_state") + + st = read_state(state_file=state_file) + if not st: + return None + try: + host = st.get("connect_host") or st.get("host") + port = int(st["port"]) + token = st["token"] + except Exception: + return None + + try: + payload = { + "type": f"{spec.protocol_prefix}.request", + "v": 1, + "id": f"{spec.protocol_prefix}-{os.getpid()}-{int(time.time() * 1000)}", + "token": token, + "work_dir": str(work_dir), + "timeout_s": float(timeout), + "quiet": bool(quiet), + "message": message, + } + connect_timeout = min(1.0, max(0.1, float(timeout))) + with socket.create_connection((host, port), timeout=connect_timeout) as sock: + sock.settimeout(0.5) + sock.sendall((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) + buf = b"" + deadline = time.time() + float(timeout) + 5.0 + while b"\n" not in buf and time.time() < deadline: + try: + chunk = sock.recv(65536) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + if b"\n" not in buf: + return None + line = buf.split(b"\n", 1)[0].decode("utf-8", errors="replace") + resp = json.loads(line) + if resp.get("type") != f"{spec.protocol_prefix}.response": + return None + reply = str(resp.get("reply") or "") + exit_code = int(resp.get("exit_code", 1)) + return reply, exit_code + except Exception: + return None + + +def maybe_start_daemon(spec: ProviderClientSpec, work_dir: Path) -> bool: + if not env_bool(spec.enabled_env, True): + return False + if not autostart_enabled(spec.autostart_env_primary, spec.autostart_env_legacy, True): + return False + if not find_project_session_file(work_dir, spec.session_filename): + return False + + candidates: list[str] = [] + local = (Path(__file__).resolve().parent.parent / "bin" / spec.daemon_bin_name) + if local.exists(): + candidates.append(str(local)) + found = shutil.which(spec.daemon_bin_name) + if found: + candidates.append(found) + if not candidates: + return False + + entry = candidates[0] + lower = entry.lower() + if lower.endswith((".cmd", ".bat", ".exe")): + argv = [entry] + else: + argv = [sys.executable, entry] + 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) + return True + except Exception: + return False + + +def wait_for_daemon_ready(spec: ProviderClientSpec, timeout_s: float = 2.0, state_file: Optional[Path] = None) -> bool: + try: + from importlib import import_module + daemon_module = import_module(spec.daemon_module) + ping_daemon = getattr(daemon_module, "ping_daemon") + except Exception: + return False + deadline = time.time() + max(0.1, float(timeout_s)) + if state_file is None: + state_file = state_file_from_env(spec.state_file_env) + while time.time() < deadline: + try: + if ping_daemon(timeout_s=0.2, state_file=state_file): + return True + except Exception: + pass + time.sleep(0.1) + return False + + +def check_background_mode() -> bool: + if os.environ.get("CLAUDECODE") != "1": + return True + if os.environ.get("CCB_ALLOW_FOREGROUND") in ("1", "true", "yes"): + return True + try: + import stat + mode = os.fstat(sys.stdout.fileno()).st_mode + return stat.S_ISREG(mode) or stat.S_ISSOCK(mode) or stat.S_ISFIFO(mode) + except Exception: + return False diff --git a/lib/askd_rpc.py b/lib/askd_rpc.py new file mode 100644 index 0000000..b974d1d --- /dev/null +++ b/lib/askd_rpc.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import socket +import time +from pathlib import Path + + +def read_state(state_file: Path) -> dict | None: + try: + raw = state_file.read_text(encoding="utf-8") + obj = json.loads(raw) + return obj if isinstance(obj, dict) else None + except Exception: + return None + + +def ping_daemon(protocol_prefix: str, timeout_s: float, state_file: Path) -> bool: + st = read_state(state_file) + if not st: + return False + try: + host = st.get("connect_host") or st["host"] + port = int(st["port"]) + token = st["token"] + except Exception: + return False + try: + with socket.create_connection((host, port), timeout=timeout_s) as sock: + req = {"type": f"{protocol_prefix}.ping", "v": 1, "id": "ping", "token": token} + sock.sendall((json.dumps(req) + "\n").encode("utf-8")) + buf = b"" + deadline = time.time() + timeout_s + while b"\n" not in buf and time.time() < deadline: + chunk = sock.recv(1024) + if not chunk: + break + buf += chunk + if b"\n" not in buf: + return False + line = buf.split(b"\n", 1)[0].decode("utf-8", errors="replace") + resp = json.loads(line) + return resp.get("type") in (f"{protocol_prefix}.pong", f"{protocol_prefix}.response") and int(resp.get("exit_code") or 0) == 0 + except Exception: + return False + + +def shutdown_daemon(protocol_prefix: str, timeout_s: float, state_file: Path) -> bool: + st = read_state(state_file) + if not st: + return False + try: + host = st.get("connect_host") or st["host"] + port = int(st["port"]) + token = st["token"] + except Exception: + return False + try: + with socket.create_connection((host, port), timeout=timeout_s) as sock: + req = {"type": f"{protocol_prefix}.shutdown", "v": 1, "id": "shutdown", "token": token} + sock.sendall((json.dumps(req) + "\n").encode("utf-8")) + _ = sock.recv(1024) + return True + except Exception: + return False diff --git a/lib/askd_runtime.py b/lib/askd_runtime.py new file mode 100644 index 0000000..dbd79b7 --- /dev/null +++ b/lib/askd_runtime.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def run_dir() -> Path: + override = (os.environ.get("CCB_RUN_DIR") or "").strip() + if override: + return Path(override).expanduser() + + if os.name == "nt": + base = (os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "").strip() + if base: + return Path(base) / "ccb" + return Path.home() / "AppData" / "Local" / "ccb" + + xdg_cache = (os.environ.get("XDG_CACHE_HOME") or "").strip() + if xdg_cache: + return Path(xdg_cache) / "ccb" + return Path.home() / ".cache" / "ccb" + + +def state_file_path(name: str) -> Path: + if name.endswith(".json"): + return run_dir() / name + return run_dir() / f"{name}.json" + + +def log_path(name: str) -> Path: + if name.endswith(".log"): + return run_dir() / name + return run_dir() / f"{name}.log" + + +def write_log(path: Path, msg: str) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(msg.rstrip() + "\n") + except Exception: + pass + + +def random_token() -> str: + return os.urandom(16).hex() + + +def normalize_connect_host(host: str) -> str: + host = (host or "").strip() + if not host or host in ("0.0.0.0",): + return "127.0.0.1" + if host in ("::", "[::]"): + return "::1" + return host diff --git a/lib/askd_server.py b/lib/askd_server.py new file mode 100644 index 0000000..08e11c8 --- /dev/null +++ b/lib/askd_server.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import json +import os +import socketserver +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Optional + +from askd_runtime import log_path, normalize_connect_host, run_dir, write_log +from process_lock import ProviderLock +from providers import ProviderDaemonSpec +from session_utils import safe_write_session + +RequestHandler = Callable[[dict], dict] + + +class AskDaemonServer: + def __init__( + self, + *, + spec: ProviderDaemonSpec, + host: str = "127.0.0.1", + port: int = 0, + token: str, + state_file: Path, + request_handler: RequestHandler, + request_queue_size: Optional[int] = None, + on_stop: Optional[Callable[[], None]] = None, + ): + self.spec = spec + self.host = host + self.port = port + self.token = token + self.state_file = state_file + self.request_handler = request_handler + self.request_queue_size = request_queue_size + self.on_stop = on_stop + + def serve_forever(self) -> int: + run_dir().mkdir(parents=True, exist_ok=True) + + lock = ProviderLock(self.spec.lock_name, cwd="global", timeout=0.1) + if not lock.try_acquire(): + return 2 + + protocol_prefix = self.spec.protocol_prefix + response_type = f"{protocol_prefix}.response" + + class Handler(socketserver.StreamRequestHandler): + def handle(self) -> None: + with self.server.activity_lock: + self.server.active_requests += 1 + self.server.last_activity = time.time() + + try: + line = self.rfile.readline() + if not line: + return + msg = json.loads(line.decode("utf-8", errors="replace")) + except Exception: + return + + if msg.get("token") != self.server.token: + self._write({"type": response_type, "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": "Unauthorized"}) + return + + msg_type = msg.get("type") + if msg_type == f"{protocol_prefix}.ping": + self._write({"type": f"{protocol_prefix}.pong", "v": 1, "id": msg.get("id"), "exit_code": 0, "reply": "OK"}) + return + + if msg_type == f"{protocol_prefix}.shutdown": + self._write({"type": response_type, "v": 1, "id": msg.get("id"), "exit_code": 0, "reply": "OK"}) + threading.Thread(target=self.server.shutdown, daemon=True).start() + return + + if msg_type != f"{protocol_prefix}.request": + self._write({"type": response_type, "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": "Invalid request"}) + return + + try: + resp = self.server.request_handler(msg) + except Exception as exc: + try: + write_log(log_path(self.server.spec.log_file_name), f"[ERROR] request handler error: {exc}") + except Exception: + pass + self._write({"type": response_type, "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": f"Internal error: {exc}"}) + return + + if isinstance(resp, dict): + self._write(resp) + else: + self._write({"type": response_type, "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": "Invalid response"}) + + def _write(self, obj: dict) -> None: + try: + data = (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8") + self.wfile.write(data) + self.wfile.flush() + try: + with self.server.activity_lock: + self.server.last_activity = time.time() + except Exception: + pass + except Exception: + pass + + def finish(self) -> None: + try: + super().finish() + finally: + try: + with self.server.activity_lock: + if self.server.active_requests > 0: + self.server.active_requests -= 1 + self.server.last_activity = time.time() + except Exception: + pass + + class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + if self.request_queue_size is not None: + try: + Server.request_queue_size = int(self.request_queue_size) + except Exception: + pass + + try: + with Server((self.host, self.port), Handler) as httpd: + httpd.spec = self.spec + httpd.token = self.token + httpd.request_handler = self.request_handler + httpd.active_requests = 0 + httpd.last_activity = time.time() + httpd.activity_lock = threading.Lock() + try: + httpd.idle_timeout_s = float(os.environ.get(self.spec.idle_timeout_env, "60") or "60") + except Exception: + httpd.idle_timeout_s = 60.0 + + def _idle_monitor() -> None: + timeout_s = float(getattr(httpd, "idle_timeout_s", 60.0) or 0.0) + if timeout_s <= 0: + return + while True: + time.sleep(0.5) + try: + with httpd.activity_lock: + active = int(httpd.active_requests or 0) + last = float(httpd.last_activity or time.time()) + except Exception: + active = 0 + last = time.time() + if active == 0 and (time.time() - last) >= timeout_s: + write_log( + log_path(self.spec.log_file_name), + f"[INFO] {self.spec.daemon_key} idle timeout ({int(timeout_s)}s) reached; shutting down", + ) + threading.Thread(target=httpd.shutdown, daemon=True).start() + return + + threading.Thread(target=_idle_monitor, daemon=True).start() + + actual_host, actual_port = httpd.server_address + self._write_state(str(actual_host), int(actual_port)) + write_log( + log_path(self.spec.log_file_name), + f"[INFO] {self.spec.daemon_key} started pid={os.getpid()} addr={actual_host}:{actual_port}", + ) + try: + httpd.serve_forever(poll_interval=0.2) + finally: + write_log(log_path(self.spec.log_file_name), f"[INFO] {self.spec.daemon_key} stopped") + if self.on_stop: + try: + self.on_stop() + except Exception: + pass + finally: + try: + lock.release() + except Exception: + pass + return 0 + + def _write_state(self, host: str, port: int) -> None: + payload = { + "pid": os.getpid(), + "host": host, + "connect_host": normalize_connect_host(host), + "port": port, + "token": self.token, + "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "python": sys.executable, + } + 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") + if ok: + if os.name != "nt": + try: + os.chmod(self.state_file, 0o600) + except Exception: + pass diff --git a/lib/caskd_daemon.py b/lib/caskd_daemon.py new file mode 100644 index 0000000..f18ebf6 --- /dev/null +++ b/lib/caskd_daemon.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Tuple + +from worker_pool import BaseSessionWorker, PerSessionWorkerPool + +from ccb_protocol import ( + CaskdRequest, + CaskdResult, + REQ_ID_PREFIX, + + make_req_id, + is_done_text, + strip_done_text, + wrap_codex_prompt, +) +from caskd_session import CodexProjectSession, compute_session_key, find_project_session_file, load_project_session +from codex_comm import CodexLogReader, CodexCommunicator +from terminal import get_backend_for_session +from askd_runtime import state_file_path, log_path, write_log, random_token +import askd_rpc +from askd_server import AskDaemonServer +from providers import CASKD_SPEC + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _extract_codex_session_id_from_log(log_path: Path) -> Optional[str]: + try: + return CodexCommunicator._extract_session_id(log_path) + except Exception: + return None + + +def _tail_state_for_log(log_path: Optional[Path], *, tail_bytes: int) -> dict: + if not log_path: + return {"log_path": None, "offset": 0} + try: + size = log_path.stat().st_size + except OSError: + size = 0 + offset = max(0, int(size) - int(tail_bytes)) + return {"log_path": log_path, "offset": offset} + + +@dataclass +class _QueuedTask: + request: CaskdRequest + created_ms: int + req_id: str + done_event: threading.Event + result: Optional[CaskdResult] = None + + +class _SessionWorker(BaseSessionWorker[_QueuedTask, CaskdResult]): + def _handle_exception(self, exc: Exception, task: _QueuedTask) -> CaskdResult: + write_log(log_path(CASKD_SPEC.log_file_name), f"[ERROR] session={self.session_key} req_id={task.req_id} {exc}") + return CaskdResult( + exit_code=1, + reply=str(exc), + req_id=task.req_id, + session_key=self.session_key, + log_path=None, + anchor_seen=False, + done_seen=False, + fallback_scan=False, + ) + + def _handle_task(self, task: _QueuedTask) -> CaskdResult: + started_ms = _now_ms() + req = task.request + work_dir = Path(req.work_dir) + write_log(log_path(CASKD_SPEC.log_file_name), f"[INFO] start session={self.session_key} req_id={task.req_id} work_dir={req.work_dir}") + session = load_project_session(work_dir) + if not session: + return CaskdResult( + exit_code=1, + reply="❌ No active Codex session found for work_dir. Run 'ccb up codex' in that project first.", + req_id=task.req_id, + session_key=self.session_key, + log_path=None, + anchor_seen=False, + done_seen=False, + fallback_scan=False, + ) + + if session.terminal not in ("wezterm", "iterm2"): + return CaskdResult( + exit_code=1, + reply=f"❌ caskd currently supports WezTerm/iTerm2 sessions only (got terminal={session.terminal}).", + req_id=task.req_id, + session_key=self.session_key, + log_path=None, + anchor_seen=False, + done_seen=False, + fallback_scan=False, + ) + + ok, pane_or_err = session.ensure_pane() + if not ok: + return CaskdResult( + exit_code=1, + reply=f"❌ Session pane not available: {pane_or_err}", + req_id=task.req_id, + session_key=self.session_key, + log_path=None, + anchor_seen=False, + done_seen=False, + fallback_scan=False, + ) + pane_id = pane_or_err + backend = get_backend_for_session(session.data) + if not backend: + return CaskdResult( + exit_code=1, + reply="❌ Terminal backend not available", + req_id=task.req_id, + session_key=self.session_key, + log_path=None, + anchor_seen=False, + done_seen=False, + fallback_scan=False, + ) + + prompt = wrap_codex_prompt(req.message, task.req_id) + + # Prefer project-bound log path if present; allow reader to follow newer logs if it changes. + preferred_log = session.codex_session_path or None + codex_session_id = session.codex_session_id or None + # Start with session_id_filter if present; drop it if we see no events early (escape hatch). + reader = CodexLogReader(log_path=preferred_log, session_id_filter=codex_session_id or None, work_dir=Path(session.work_dir)) + + state = reader.capture_state() + + backend.send_text(pane_id, prompt) + + deadline = time.time() + float(req.timeout_s) + chunks: list[str] = [] + anchor_seen = False + done_seen = False + anchor_ms: Optional[int] = None + done_ms: Optional[int] = None + fallback_scan = False + + # If we can't observe our user anchor within a short grace window, the log binding is likely stale. + # In that case we drop the bound session filter and rebind to the latest log, starting from a tail + # offset (NOT EOF) to avoid missing a reply that already landed. + anchor_grace_deadline = min(deadline, time.time() + 1.5) + anchor_collect_grace = min(deadline, time.time() + 2.0) + rebounded = False + saw_any_event = False + tail_bytes = int(os.environ.get("CCB_CASKD_REBIND_TAIL_BYTES", str(1024 * 1024 * 2)) or (1024 * 1024 * 2)) + last_pane_check = time.time() + pane_check_interval = float(os.environ.get("CCB_CASKD_PANE_CHECK_INTERVAL", "2.0") or "2.0") + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + + # Fail fast if the pane dies mid-request (e.g. Codex killed). + if time.time() - last_pane_check >= pane_check_interval: + try: + alive = bool(backend.is_alive(pane_id)) + except Exception: + alive = False + if not alive: + write_log(log_path(CASKD_SPEC.log_file_name), f"[ERROR] Pane {pane_id} died during request session={self.session_key} req_id={task.req_id}") + codex_log_path = None + try: + lp = reader.current_log_path() + if lp: + codex_log_path = str(lp) + except Exception: + codex_log_path = None + return CaskdResult( + exit_code=1, + reply="❌ Codex pane died during request", + req_id=task.req_id, + session_key=self.session_key, + log_path=codex_log_path, + anchor_seen=anchor_seen, + done_seen=False, + fallback_scan=fallback_scan, + anchor_ms=anchor_ms, + done_ms=None, + ) + # Check for Codex interrupted state + # Only trigger if "■ Conversation interrupted" appears AFTER "CCB_REQ_ID" (our request) + # This ensures we're detecting interrupt for current task, not history + if hasattr(backend, 'get_text'): + try: + pane_text = backend.get_text(pane_id, lines=15) + if pane_text and '■ Conversation interrupted' in pane_text: + # Verify this is for current request: interrupt should appear after our req_id + req_id_pos = pane_text.find(task.req_id) + interrupt_pos = pane_text.find('■ Conversation interrupted') + # Only trigger if interrupt is after our request ID (or if req_id not found but interrupt is recent) + is_current_interrupt = (req_id_pos >= 0 and interrupt_pos > req_id_pos) or (req_id_pos < 0 and interrupt_pos >= 0) + else: + is_current_interrupt = False + if is_current_interrupt: + write_log(log_path(CASKD_SPEC.log_file_name), f"[WARN] Codex interrupted - skipping task session={self.session_key} req_id={task.req_id}") + codex_log_path = None + try: + lp = reader.current_log_path() + if lp: + codex_log_path = str(lp) + except Exception: + codex_log_path = None + return CaskdResult( + exit_code=1, + reply="❌ Codex interrupted. Please recover Codex manually, then retry. Skipping to next task.", + req_id=task.req_id, + session_key=self.session_key, + log_path=codex_log_path, + anchor_seen=anchor_seen, + done_seen=False, + fallback_scan=fallback_scan, + anchor_ms=anchor_ms, + done_ms=None, + ) + except Exception: + pass + last_pane_check = time.time() + + event, state = reader.wait_for_event(state, min(remaining, 0.5)) + if event is None: + if (not rebounded) and (not anchor_seen) and time.time() >= anchor_grace_deadline and codex_session_id: + # Escape hatch: drop the session_id_filter so the reader can follow the latest log for this work_dir. + codex_session_id = None + reader = CodexLogReader(log_path=preferred_log, session_id_filter=None, work_dir=Path(session.work_dir)) + log_hint = reader.current_log_path() + state = _tail_state_for_log(log_hint, tail_bytes=tail_bytes) + fallback_scan = True + rebounded = True + continue + + role, text = event + saw_any_event = True + if role == "user": + if f"{REQ_ID_PREFIX} {task.req_id}" in text: + anchor_seen = True + if anchor_ms is None: + anchor_ms = _now_ms() - started_ms + continue + + if role != "assistant": + continue + + # Avoid collecting unrelated assistant messages until our request is visible in logs. + # Some Codex builds may omit user entries; after a short grace period, start collecting anyway. + if (not anchor_seen) and time.time() < anchor_collect_grace: + continue + + chunks.append(text) + combined = "\n".join(chunks) + if is_done_text(combined, task.req_id): + done_seen = True + done_ms = _now_ms() - started_ms + break + + combined = "\n".join(chunks) + reply = strip_done_text(combined, task.req_id) + codex_log_path = None + try: + lp = state.get("log_path") + if lp: + codex_log_path = str(lp) + except Exception: + codex_log_path = None + + if done_seen and codex_log_path: + sid = _extract_codex_session_id_from_log(Path(codex_log_path)) + session.update_codex_log_binding(log_path=codex_log_path, session_id=sid) + + exit_code = 0 if done_seen else 2 + result = CaskdResult( + exit_code=exit_code, + reply=reply, + req_id=task.req_id, + session_key=self.session_key, + log_path=codex_log_path, + anchor_seen=anchor_seen, + done_seen=done_seen, + fallback_scan=fallback_scan, + anchor_ms=anchor_ms, + done_ms=done_ms, + ) + write_log(log_path(CASKD_SPEC.log_file_name), + f"[INFO] done session={self.session_key} req_id={task.req_id} exit={result.exit_code} " + f"anchor={result.anchor_seen} done={result.done_seen} fallback={result.fallback_scan} " + f"log={result.log_path or ''} anchor_ms={result.anchor_ms or ''} done_ms={result.done_ms or ''}" + ) + return result + + +@dataclass +class _SessionEntry: + work_dir: Path + session: Optional[CodexProjectSession] + session_file: Optional[Path] + file_mtime: float + last_check: float + valid: bool = True + + +class SessionRegistry: + """Manages and monitors all active Codex sessions.""" + + CHECK_INTERVAL = 10.0 # seconds between validity checks + + def __init__(self): + self._lock = threading.Lock() + self._sessions: dict[str, _SessionEntry] = {} # work_dir -> entry + self._stop = threading.Event() + self._monitor_thread: Optional[threading.Thread] = None + + def start_monitor(self) -> None: + if self._monitor_thread is None: + self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._monitor_thread.start() + + def stop_monitor(self) -> None: + self._stop.set() + + def get_session(self, work_dir: Path) -> Optional[CodexProjectSession]: + key = str(work_dir) + with self._lock: + entry = self._sessions.get(key) + if entry: + # If the session entry is invalid but the session file was updated (e.g. new pane info), + # reload and re-validate so we can recover. + session_file = entry.session_file or find_project_session_file(work_dir) or (work_dir / ".codex-session") + if session_file.exists(): + try: + current_mtime = session_file.stat().st_mtime + if (not entry.session_file) or (session_file != entry.session_file) or (current_mtime != entry.file_mtime): + write_log(log_path(CASKD_SPEC.log_file_name), f"[INFO] Session file changed, reloading: {work_dir}") + entry = self._load_and_cache(work_dir) + except Exception: + pass + + if entry and entry.valid: + return entry.session + else: + entry = self._load_and_cache(work_dir) + if entry: + return entry.session + + return None + + def _load_and_cache(self, work_dir: Path) -> Optional[_SessionEntry]: + session = load_project_session(work_dir) + session_file = session.session_file if session else (find_project_session_file(work_dir) or (work_dir / ".codex-session")) + mtime = 0.0 + if session_file.exists(): + try: + mtime = session_file.stat().st_mtime + except Exception: + pass + + valid = False + if session is not None: + try: + ok, _ = session.ensure_pane() + valid = bool(ok) + except Exception: + valid = False + + entry = _SessionEntry( + work_dir=work_dir, + session=session, + session_file=session_file if session_file.exists() else None, + file_mtime=mtime, + last_check=time.time(), + valid=valid, + ) + self._sessions[str(work_dir)] = entry + return entry if entry.valid else None + + def invalidate(self, work_dir: Path) -> None: + key = str(work_dir) + with self._lock: + if key in self._sessions: + self._sessions[key].valid = False + write_log(log_path(CASKD_SPEC.log_file_name), f"[INFO] Session invalidated: {work_dir}") + + def remove(self, work_dir: Path) -> None: + key = str(work_dir) + with self._lock: + if key in self._sessions: + del self._sessions[key] + write_log(log_path(CASKD_SPEC.log_file_name), f"[INFO] Session removed: {work_dir}") + + def _monitor_loop(self) -> None: + while not self._stop.wait(self.CHECK_INTERVAL): + self._check_all_sessions() + + def _check_all_sessions(self) -> None: + with self._lock: + keys_to_remove = [] + for key, entry in self._sessions.items(): + if not entry.valid: + continue + if entry.session_file and not entry.session_file.exists(): + write_log(log_path(CASKD_SPEC.log_file_name), f"[WARN] Session file deleted: {entry.work_dir}") + entry.valid = False + continue + if entry.session: + ok, _ = entry.session.ensure_pane() + if not ok: + write_log(log_path(CASKD_SPEC.log_file_name), f"[WARN] Session pane invalid: {entry.work_dir}") + entry.valid = False + entry.last_check = time.time() + for key, entry in list(self._sessions.items()): + if not entry.valid and time.time() - entry.last_check > 300: + keys_to_remove.append(key) + for key in keys_to_remove: + del self._sessions[key] + + def get_status(self) -> dict: + with self._lock: + return { + "total": len(self._sessions), + "valid": sum(1 for e in self._sessions.values() if e.valid), + "sessions": [{"work_dir": str(e.work_dir), "valid": e.valid} for e in self._sessions.values()], + } + + +_session_registry: Optional[SessionRegistry] = None + + +def get_session_registry() -> SessionRegistry: + global _session_registry + if _session_registry is None: + _session_registry = SessionRegistry() + _session_registry.start_monitor() + return _session_registry + + +class _WorkerPool: + def __init__(self): + self._pool = PerSessionWorkerPool[_SessionWorker]() + + def submit(self, request: CaskdRequest) -> _QueuedTask: + req_id = make_req_id() + task = _QueuedTask(request=request, created_ms=_now_ms(), req_id=req_id, done_event=threading.Event()) + + session = load_project_session(Path(request.work_dir)) + session_key = compute_session_key(session) if session else "codex:unknown" + + worker = self._pool.get_or_create(session_key, _SessionWorker) + worker.enqueue(task) + return task + + +class CaskdServer: + def __init__(self, host: str = "127.0.0.1", port: int = 0, *, state_file: Optional[Path] = None): + self.host = host + self.port = port + self.state_file = state_file or state_file_path(CASKD_SPEC.state_file_name) + self.token = random_token() + self.pool = _WorkerPool() + + def serve_forever(self) -> int: + def _handle_request(msg: dict) -> dict: + try: + req = CaskdRequest( + client_id=str(msg.get("id") or ""), + work_dir=str(msg.get("work_dir") or ""), + timeout_s=float(msg.get("timeout_s") or 300.0), + quiet=bool(msg.get("quiet") or False), + message=str(msg.get("message") or ""), + output_path=str(msg.get("output_path")) if msg.get("output_path") else None, + ) + except Exception as exc: + return {"type": "cask.response", "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": f"Bad request: {exc}"} + + task = self.pool.submit(req) + task.done_event.wait(timeout=req.timeout_s + 5.0) + result = task.result + if not result: + return {"type": "cask.response", "v": 1, "id": req.client_id, "exit_code": 2, "reply": ""} + + return { + "type": "cask.response", + "v": 1, + "id": req.client_id, + "req_id": result.req_id, + "exit_code": result.exit_code, + "reply": result.reply, + "meta": { + "session_key": result.session_key, + "log_path": result.log_path, + "anchor_seen": result.anchor_seen, + "done_seen": result.done_seen, + "fallback_scan": result.fallback_scan, + "anchor_ms": result.anchor_ms, + "done_ms": result.done_ms, + }, + } + + server = AskDaemonServer( + spec=CASKD_SPEC, + host=self.host, + port=self.port, + token=self.token, + state_file=self.state_file, + request_handler=_handle_request, + ) + return server.serve_forever() + + +def read_state(state_file: Optional[Path] = None) -> Optional[dict]: + state_file = state_file or state_file_path(CASKD_SPEC.state_file_name) + return askd_rpc.read_state(state_file) + + +def ping_daemon(timeout_s: float = 0.5, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(CASKD_SPEC.state_file_name) + return askd_rpc.ping_daemon("cask", timeout_s, state_file) + + +def shutdown_daemon(timeout_s: float = 1.0, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(CASKD_SPEC.state_file_name) + return askd_rpc.shutdown_daemon("cask", timeout_s, state_file) diff --git a/lib/caskd_protocol.py b/lib/caskd_protocol.py new file mode 100644 index 0000000..ada86a3 --- /dev/null +++ b/lib/caskd_protocol.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +# Backwards compatibility shim (internal modules should prefer `ccb_protocol`). +from ccb_protocol import * # noqa: F401,F403 diff --git a/lib/caskd_session.py b/lib/caskd_session.py new file mode 100644 index 0000000..7dd2778 --- /dev/null +++ b/lib/caskd_session.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from ccb_config import apply_backend_env +from session_utils import find_project_session_file as _find_project_session_file, safe_write_session +from terminal import get_backend_for_session + +apply_backend_env() + + +def find_project_session_file(work_dir: Path) -> Optional[Path]: + return _find_project_session_file(work_dir, ".codex-session") + + +def _read_json(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8-sig") + obj = json.loads(raw) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +def _now_str() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class CodexProjectSession: + session_file: Path + data: dict + + @property + def terminal(self) -> str: + return (self.data.get("terminal") or "tmux").strip() or "tmux" + + @property + def pane_id(self) -> str: + v = self.data.get("pane_id") if self.terminal in ("wezterm", "iterm2") else self.data.get("tmux_session") + return str(v or "").strip() + + @property + def pane_title_marker(self) -> str: + return str(self.data.get("pane_title_marker") or "").strip() + + @property + def codex_session_path(self) -> str: + return str(self.data.get("codex_session_path") or "").strip() + + @property + def codex_session_id(self) -> str: + return str(self.data.get("codex_session_id") or "").strip() + + @property + def work_dir(self) -> str: + return str(self.data.get("work_dir") or self.session_file.parent) + + def backend(self): + return get_backend_for_session(self.data) + + def ensure_pane(self) -> Tuple[bool, str]: + backend = self.backend() + if not backend: + return False, "Terminal backend not available" + pane_id = self.pane_id + if pane_id and backend.is_alive(pane_id): + return True, pane_id + marker = self.pane_title_marker + resolver = getattr(backend, "find_pane_by_title_marker", None) + if marker and callable(resolver): + resolved = resolver(marker) + if resolved: + self.data["pane_id"] = str(resolved) + self.data["updated_at"] = _now_str() + self._write_back() + return True, str(resolved) + return False, f"Pane not alive: {pane_id}" + + def update_codex_log_binding(self, *, log_path: Optional[str], session_id: Optional[str]) -> None: + updated = False + if log_path and self.data.get("codex_session_path") != log_path: + self.data["codex_session_path"] = log_path + updated = True + if session_id and self.data.get("codex_session_id") != session_id: + self.data["codex_session_id"] = session_id + self.data["codex_start_cmd"] = f"codex resume {session_id}" + updated = True + if updated: + self.data["updated_at"] = _now_str() + if self.data.get("active") is False: + self.data["active"] = True + self._write_back() + + def _write_back(self) -> None: + payload = json.dumps(self.data, ensure_ascii=False, indent=2) + "\n" + ok, err = safe_write_session(self.session_file, payload) + if not ok: + # Best-effort: never raise (daemon should continue). + _ = err + + +def load_project_session(work_dir: Path) -> Optional[CodexProjectSession]: + session_file = find_project_session_file(work_dir) + if not session_file: + return None + data = _read_json(session_file) + if not data: + return None + return CodexProjectSession(session_file=session_file, data=data) + + +def compute_session_key(session: CodexProjectSession) -> str: + # Use a stable per-pane key for serialization. + # codex_session_id can change when the user runs `codex resume` inside the same pane; using it as a key + # can accidentally create a second worker and cause concurrent injection to the same pane. + marker = session.pane_title_marker + if marker: + return f"codex_marker:{marker}" + pane = session.pane_id + if pane: + return f"codex_pane:{pane}" + sid = session.codex_session_id + if sid: + return f"codex:{sid}" + return f"codex_file:{session.session_file}" diff --git a/lib/ccb_config.py b/lib/ccb_config.py new file mode 100644 index 0000000..15ac039 --- /dev/null +++ b/lib/ccb_config.py @@ -0,0 +1,83 @@ +"""CCB configuration for Windows/WSL backend environment""" +import json +import os +import subprocess +import sys +from pathlib import Path + + +def get_backend_env() -> str | None: + """Get BackendEnv from env var or .ccb-config.json""" + v = (os.environ.get("CCB_BACKEND_ENV") or "").strip().lower() + if v in {"wsl", "windows"}: + return v + path = Path.cwd() / ".ccb-config.json" + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + v = (data.get("BackendEnv") or "").strip().lower() + if v in {"wsl", "windows"}: + return v + except Exception: + pass + return "windows" if sys.platform == "win32" else None + + +def _wsl_probe_distro_and_home() -> tuple[str, str]: + """Probe default WSL distro and home directory""" + try: + r = subprocess.run( + ["wsl.exe", "-e", "sh", "-lc", "echo $WSL_DISTRO_NAME; echo $HOME"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10 + ) + if r.returncode == 0: + lines = r.stdout.strip().split("\n") + if len(lines) >= 2: + return lines[0].strip(), lines[1].strip() + except Exception: + pass + try: + r = subprocess.run( + ["wsl.exe", "-l", "-q"], + capture_output=True, text=True, encoding="utf-16-le", errors="replace", timeout=5 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + distro = line.strip().strip("\x00") + if distro: + break + else: + distro = "Ubuntu" + else: + distro = "Ubuntu" + except Exception: + distro = "Ubuntu" + try: + r = subprocess.run( + ["wsl.exe", "-d", distro, "-e", "sh", "-lc", "echo $HOME"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5 + ) + home = r.stdout.strip() if r.returncode == 0 else "/root" + except Exception: + home = "/root" + return distro, home + + +def apply_backend_env() -> None: + """Apply BackendEnv=wsl settings (set session root paths for Windows to access WSL)""" + if sys.platform != "win32" or get_backend_env() != "wsl": + return + if os.environ.get("CODEX_SESSION_ROOT") and os.environ.get("GEMINI_ROOT"): + return + distro, home = _wsl_probe_distro_and_home() + for base in (fr"\\wsl.localhost\{distro}", fr"\\wsl$\{distro}"): + prefix = base + home.replace("/", "\\") + codex_path = prefix + r"\.codex\sessions" + gemini_path = prefix + r"\.gemini\tmp" + if Path(codex_path).exists() or Path(gemini_path).exists(): + os.environ.setdefault("CODEX_SESSION_ROOT", codex_path) + os.environ.setdefault("GEMINI_ROOT", gemini_path) + return + prefix = fr"\\wsl.localhost\{distro}" + home.replace("/", "\\") + os.environ.setdefault("CODEX_SESSION_ROOT", prefix + r"\.codex\sessions") + os.environ.setdefault("GEMINI_ROOT", prefix + r"\.gemini\tmp") diff --git a/lib/ccb_protocol.py b/lib/ccb_protocol.py new file mode 100644 index 0000000..e952b42 --- /dev/null +++ b/lib/ccb_protocol.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import re +import secrets +from dataclasses import dataclass + + +REQ_ID_PREFIX = "CCB_REQ_ID:" +DONE_PREFIX = "CCB_DONE:" + +DONE_LINE_RE_TEMPLATE = r"^\s*CCB_DONE:\s*{req_id}\s*$" + + +def make_req_id() -> str: + # 128-bit token is enough; hex string is log/grep friendly. + return secrets.token_hex(16) + + +def wrap_codex_prompt(message: str, req_id: str) -> str: + message = (message or "").rstrip() + return ( + f"{REQ_ID_PREFIX} {req_id}\n\n" + f"{message}\n\n" + "IMPORTANT:\n" + "- Reply normally.\n" + "- End your reply with this exact final line (verbatim, on its own line):\n" + f"{DONE_PREFIX} {req_id}\n" + ) + + +def done_line_re(req_id: str) -> re.Pattern[str]: + return re.compile(DONE_LINE_RE_TEMPLATE.format(req_id=re.escape(req_id))) + + +def is_done_text(text: str, req_id: str) -> bool: + lines = [ln.rstrip() for ln in (text or "").splitlines()] + for i in range(len(lines) - 1, -1, -1): + if lines[i].strip() == "": + continue + return bool(done_line_re(req_id).match(lines[i])) + return False + + +def strip_done_text(text: str, req_id: str) -> str: + lines = [ln.rstrip("\n") for ln in (text or "").splitlines()] + if not lines: + return "" + i = len(lines) - 1 + while i >= 0 and lines[i].strip() == "": + i -= 1 + if i >= 0 and done_line_re(req_id).match(lines[i] or ""): + lines = lines[:i] + return "\n".join(lines).rstrip() + + +@dataclass(frozen=True) +class CaskdRequest: + client_id: str + work_dir: str + timeout_s: float + quiet: bool + message: str + output_path: str | None = None + + +@dataclass(frozen=True) +class CaskdResult: + exit_code: int + reply: str + req_id: str + session_key: str + log_path: str | None + anchor_seen: bool + done_seen: bool + fallback_scan: bool + anchor_ms: int | None = None + done_ms: int | None = None diff --git a/lib/cli_output.py b/lib/cli_output.py new file mode 100644 index 0000000..356c4ff --- /dev/null +++ b/lib/cli_output.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Optional + + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_REPLY = 2 + + +def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + fd: Optional[int] = None + tmp_path: Optional[str] = None + try: + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + with os.fdopen(fd, "w", encoding=encoding, newline="\n") as handle: + handle.write(content) + fd = None + os.replace(tmp_path, path) + tmp_path = None + finally: + if fd is not None: + try: + os.close(fd) + except Exception: + pass + if tmp_path: + try: + os.unlink(tmp_path) + except Exception: + pass + + +def normalize_message_parts(parts: list[str]) -> str: + return " ".join(parts).strip() + diff --git a/lib/codex_comm.py b/lib/codex_comm.py index 89237d9..9ebdcbb 100644 --- a/lib/codex_comm.py +++ b/lib/codex_comm.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python3 """ -Codex 通信模块(日志驱动版本) -通过 FIFO 发送请求,并从 ~/.codex/sessions 下的官方日志解析回复。 +Codex communication module (log-driven version) +Sends requests via FIFO and parses replies from ~/.codex/sessions logs. """ from __future__ import annotations @@ -9,13 +8,19 @@ import json import os import re +import sys import time import shlex from datetime import datetime from pathlib import Path -from typing import Optional, Tuple, Dict, Any +from typing import Optional, Tuple, Dict, Any, List from terminal import get_backend_for_session, get_pane_id_from_session +from ccb_config import apply_backend_env +from i18n import t +from pane_registry import upsert_registry, registry_path_for_session, load_registry_by_session_id + +apply_backend_env() SESSION_ROOT = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser() SESSION_ID_PATTERN = re.compile( @@ -25,21 +30,116 @@ class CodexLogReader: - """读取 ~/.codex/sessions 内的 Codex 官方日志""" + """Reads Codex official logs from ~/.codex/sessions""" - def __init__(self, root: Path = SESSION_ROOT, log_path: Optional[Path] = None, session_id_filter: Optional[str] = None): + def __init__(self, root: Path = SESSION_ROOT, log_path: Optional[Path] = None, + session_id_filter: Optional[str] = None, work_dir: Optional[Path] = None): self.root = Path(root).expanduser() self._preferred_log = self._normalize_path(log_path) self._session_id_filter = session_id_filter + self._work_dir = self._normalize_work_dir(work_dir) try: poll = float(os.environ.get("CODEX_POLL_INTERVAL", "0.05")) except Exception: poll = 0.05 self._poll_interval = min(0.5, max(0.01, poll)) + @staticmethod + def _debug_enabled() -> bool: + return os.environ.get("CCB_DEBUG") in ("1", "true", "yes") or os.environ.get("CPEND_DEBUG") in ( + "1", + "true", + "yes", + ) + + @classmethod + def _debug(cls, message: str) -> None: + if not cls._debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + @staticmethod + def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw == "": + return default + try: + return int(raw) + except ValueError: + return default + + def _iter_lines_reverse(self, log_path: Path, *, max_bytes: int, max_lines: int) -> List[str]: + """ + Read lines from the end of a file (reverse order), bounded by max_bytes/max_lines. + Returns a list in reverse chronological order (last line first). + """ + if max_bytes <= 0 or max_lines <= 0: + return [] + + try: + with log_path.open("rb") as handle: + handle.seek(0, os.SEEK_END) + position = handle.tell() + bytes_read = 0 + lines: List[str] = [] + buffer = b"" + + while position > 0 and bytes_read < max_bytes and len(lines) < max_lines: + remaining = max_bytes - bytes_read + read_size = min(8192, position, remaining) + position -= read_size + handle.seek(position, os.SEEK_SET) + chunk = handle.read(read_size) + bytes_read += len(chunk) + buffer = chunk + buffer + + parts = buffer.split(b"\n") + buffer = parts[0] + for part in reversed(parts[1:]): + if len(lines) >= max_lines: + break + text = part.decode("utf-8", errors="ignore").strip() + if text: + lines.append(text) + + if position == 0 and buffer and len(lines) < max_lines: + text = buffer.decode("utf-8", errors="ignore").strip() + if text: + lines.append(text) + + return lines + except OSError as exc: + self._debug(f"Failed reading log tail: {log_path} ({exc})") + return [] + def set_preferred_log(self, log_path: Optional[Path]) -> None: self._preferred_log = self._normalize_path(log_path) + def _normalize_work_dir(self, work_dir: Optional[Path]) -> Optional[str]: + """Normalize work_dir for comparison with cwd in session logs""" + if work_dir is None: + work_dir = Path.cwd() + try: + return str(work_dir.resolve()).lower() + except Exception: + return None + + def _extract_cwd_from_log(self, log_path: Path) -> Optional[str]: + """Extract cwd from session_meta in the first line of log file""" + try: + with log_path.open("r", encoding="utf-8") as f: + first_line = f.readline() + if not first_line: + return None + entry = json.loads(first_line) + if entry.get("type") == "session_meta": + cwd = entry.get("payload", {}).get("cwd") + if cwd: + return str(Path(cwd).resolve()).lower() + except Exception: + pass + return None + def _normalize_path(self, value: Optional[Any]) -> Optional[Path]: if value in (None, ""): return None @@ -58,9 +158,17 @@ def _scan_latest(self) -> Optional[Path]: latest: Optional[Path] = None latest_mtime = -1.0 for p in (p for p in self.root.glob("**/*.jsonl") if p.is_file()): - try: - if self._session_id_filter and self._session_id_filter not in p.name: + if self._session_id_filter: + try: + if str(self._session_id_filter).lower() not in str(p).lower(): + continue + except Exception: + pass + if self._work_dir: + cwd = self._extract_cwd_from_log(p) + if not cwd or cwd != self._work_dir: continue + try: mtime = p.stat().st_mtime except OSError: continue @@ -75,58 +183,90 @@ def _scan_latest(self) -> Optional[Path]: def _latest_log(self) -> Optional[Path]: preferred = self._preferred_log if preferred and preferred.exists(): + # If we're bound to a specific session id, prefer that log without cross-session scanning. + if self._session_id_filter: + self._debug(f"Using preferred log (bound): {preferred}") + return preferred + + # Otherwise, keep following the most recently updated log for this work dir. + latest = self._scan_latest() + if latest and latest != preferred: + try: + preferred_mtime = preferred.stat().st_mtime + latest_mtime = latest.stat().st_mtime + if latest_mtime > preferred_mtime: + self._preferred_log = latest + self._debug(f"Preferred log stale; switching to latest: {latest}") + return latest + except OSError: + self._preferred_log = latest + self._debug(f"Preferred log stat failed; switching to latest: {latest}") + return latest + self._debug(f"Using preferred log: {preferred}") return preferred + + self._debug("No valid preferred log, scanning...") latest = self._scan_latest() if latest: self._preferred_log = latest - return latest + self._debug(f"Scan found: {latest}") + return latest + return None def current_log_path(self) -> Optional[Path]: return self._latest_log() def capture_state(self) -> Dict[str, Any]: - """记录当前日志与偏移""" + """Capture current log path and offset""" log = self._latest_log() - offset = 0 + offset = -1 if log and log.exists(): try: offset = log.stat().st_size except OSError: - offset = 0 + try: + with log.open("rb") as handle: + handle.seek(0, os.SEEK_END) + offset = handle.tell() + except OSError: + offset = -1 return {"log_path": log, "offset": offset} def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: - """阻塞等待新的回复""" + """Block and wait for new reply""" return self._read_since(state, timeout, block=True) def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: - """非阻塞读取回复""" + """Non-blocking read for reply""" return self._read_since(state, timeout=0.0, block=False) + def wait_for_event(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[Tuple[str, str]], Dict[str, Any]]: + """ + Block and wait for a new event. + + Returns: + ((role, text), new_state) or (None, state) on timeout. + """ + return self._read_event_since(state, timeout, block=True) + + def try_get_event(self, state: Dict[str, Any]) -> Tuple[Optional[Tuple[str, str]], Dict[str, Any]]: + """Non-blocking read for an event.""" + return self._read_event_since(state, timeout=0.0, block=False) + def latest_message(self) -> Optional[str]: - """直接获取最新一条回复""" + """Get the latest reply directly""" + # Always use _latest_log() to detect newer sessions log_path = self._latest_log() if not log_path or not log_path.exists(): return None - try: - with log_path.open("rb") as handle: - handle.seek(0, os.SEEK_END) - buffer = bytearray() - position = handle.tell() - while position > 0 and len(buffer) < 1024 * 256: - read_size = min(4096, position) - position -= read_size - handle.seek(position) - buffer = handle.read(read_size) + buffer - if buffer.count(b"\n") >= 50: - break - lines = buffer.decode("utf-8", errors="ignore").splitlines() - except OSError: + tail_bytes = self._env_int("CODEX_LOG_TAIL_BYTES", 1024 * 1024 * 8) + tail_lines = self._env_int("CODEX_LOG_TAIL_LINES", 5000) + lines = self._iter_lines_reverse(log_path, max_bytes=tail_bytes, max_lines=tail_lines) + if not lines: return None - for line in reversed(lines): - line = line.strip() - if not line: + for line in lines: + if not line.startswith("{"): continue try: entry = json.loads(line) @@ -135,12 +275,15 @@ def latest_message(self) -> Optional[str]: message = self._extract_message(entry) if message: return message + self._debug(f"No reply found in tail (bytes={tail_bytes}, lines={tail_lines}) for log: {log_path}") return None def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: deadline = time.time() + timeout current_path = self._normalize_path(state.get("log_path")) - offset = state.get("offset", 0) + offset = state.get("offset", -1) + if not isinstance(offset, int): + offset = -1 # Keep rescans infrequent; new messages usually append to the same log file. rescan_interval = min(2.0, max(0.2, timeout / 2.0)) last_rescan = time.time() @@ -157,7 +300,7 @@ def ensure_log() -> Path: if latest: self._preferred_log = latest return latest - raise FileNotFoundError("未找到 Codex session 日志") + raise FileNotFoundError("Codex session log not found") while True: try: @@ -168,16 +311,40 @@ def ensure_log() -> Path: time.sleep(self._poll_interval) continue - with log_path.open("r", encoding="utf-8", errors="ignore") as fh: - fh.seek(offset) + try: + size = log_path.stat().st_size + except OSError: + size = None + + # If caller couldn't capture a baseline, establish it now (start from EOF). + if offset < 0: + offset = size if isinstance(size, int) else 0 + + with log_path.open("rb") as fh: + try: + if isinstance(size, int) and offset > size: + offset = size + fh.seek(offset, os.SEEK_SET) + except OSError: + # If seek fails, reset to EOF and try again on next loop. + offset = size if isinstance(size, int) else 0 + if not block: + return None, {"log_path": log_path, "offset": offset} + time.sleep(self._poll_interval) + continue while True: if block and time.time() >= deadline: return None, {"log_path": log_path, "offset": offset} - line = fh.readline() - if not line: + pos_before = fh.tell() + raw_line = fh.readline() + if not raw_line: + break + # If we hit EOF without a newline, the writer may still be appending this line. + if not raw_line.endswith(b"\n"): + fh.seek(pos_before) break offset = fh.tell() - line = line.strip() + line = raw_line.decode("utf-8", errors="ignore").strip() if not line: continue try: @@ -193,13 +360,113 @@ def ensure_log() -> Path: if latest and latest != log_path: current_path = latest self._preferred_log = latest + # When switching to a new log file (session rotation / new session), + # start from the beginning to avoid missing a reply that was already written + # before we noticed the new file. + offset = 0 + if not block: + return None, {"log_path": current_path, "offset": offset} + time.sleep(self._poll_interval) + last_rescan = time.time() + continue + last_rescan = time.time() + + if not block: + return None, {"log_path": log_path, "offset": offset} + + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, {"log_path": log_path, "offset": offset} + + def _read_event_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[Tuple[str, str]], Dict[str, Any]]: + """ + Like _read_since(), but returns structured (role, text) events. + + Role is one of: "user", "assistant". + """ + deadline = time.time() + timeout + current_path = self._normalize_path(state.get("log_path")) + offset = state.get("offset", -1) + if not isinstance(offset, int): + offset = -1 + rescan_interval = min(2.0, max(0.2, timeout / 2.0)) + last_rescan = time.time() + + def ensure_log() -> Path: + candidates = [ + self._preferred_log if self._preferred_log and self._preferred_log.exists() else None, + current_path if current_path and current_path.exists() else None, + ] + for candidate in candidates: + if candidate: + return candidate + latest = self._scan_latest() + if latest: + self._preferred_log = latest + return latest + raise FileNotFoundError("Codex session log not found") + + while True: + try: + log_path = ensure_log() + except FileNotFoundError: + if not block: + return None, {"log_path": None, "offset": 0} + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, {"log_path": None, "offset": 0} + continue + + try: + size = log_path.stat().st_size + except OSError: + size = None + + if offset < 0: + offset = size if isinstance(size, int) else 0 + + with log_path.open("rb") as fh: + try: + if isinstance(size, int) and offset > size: + offset = size + fh.seek(offset, os.SEEK_SET) + except OSError: + offset = size if isinstance(size, int) else 0 + if not block: + return None, {"log_path": log_path, "offset": offset} + time.sleep(self._poll_interval) + continue + while True: + if block and time.time() >= deadline: + return None, {"log_path": log_path, "offset": offset} + pos_before = fh.tell() + raw_line = fh.readline() + if not raw_line: + break + if not raw_line.endswith(b"\n"): + fh.seek(pos_before) + break + offset = fh.tell() + line = raw_line.decode("utf-8", errors="ignore").strip() + if not line: + continue try: - offset = latest.stat().st_size - except OSError: - offset = 0 + entry = json.loads(line) + except json.JSONDecodeError: + continue + event = self._extract_event(entry) + if event is not None: + return event, {"log_path": log_path, "offset": offset} + + if time.time() - last_rescan >= rescan_interval: + latest = self._scan_latest() + if latest and latest != log_path: + current_path = latest + self._preferred_log = latest + offset = 0 if not block: return None, {"log_path": current_path, "offset": offset} - time.sleep(0.05) + time.sleep(self._poll_interval) last_rescan = time.time() continue last_rescan = time.time() @@ -213,62 +480,204 @@ def ensure_log() -> Path: @staticmethod def _extract_message(entry: dict) -> Optional[str]: - if entry.get("type") != "response_item": - return None + entry_type = entry.get("type") payload = entry.get("payload", {}) - if payload.get("type") != "message": + + if entry_type == "response_item": + if payload.get("type") != "message": + return None + if payload.get("role") == "user": + return None + + content = payload.get("content") or [] + if isinstance(content, list): + texts: List[str] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") in ("output_text", "text"): + text = item.get("text") + if isinstance(text, str) and text.strip(): + texts.append(text.strip()) + if texts: + return "\n".join(texts).strip() + elif isinstance(content, str) and content.strip(): + return content.strip() + + message = payload.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() return None - content = payload.get("content") or [] - texts = [item.get("text", "") for item in content if item.get("type") == "output_text"] - if texts: - return "\n".join(filter(None, texts)).strip() + if entry_type == "event_msg": + payload_type = payload.get("type") + if payload_type in ("agent_message", "assistant_message", "assistant", "assistant_response", "message"): + if payload.get("role") == "user": + return None + msg = payload.get("message") or payload.get("content") or payload.get("text") + if isinstance(msg, str) and msg.strip(): + return msg.strip() + return None + + # Fallback: some Codex builds may emit assistant messages with a role field but different entry types. + if payload.get("role") == "assistant": + msg = payload.get("message") or payload.get("content") or payload.get("text") + if isinstance(msg, str) and msg.strip(): + return msg.strip() + return None + + @staticmethod + def _extract_user_message(entry: dict) -> Optional[str]: + """Extract user question from a JSONL entry""" + entry_type = entry.get("type") + payload = entry.get("payload", {}) + + if entry_type == "event_msg" and payload.get("type") == "user_message": + msg = payload.get("message", "") + if isinstance(msg, str) and msg.strip(): + return msg.strip() + + if entry_type == "response_item": + if payload.get("type") == "message" and payload.get("role") == "user": + content = payload.get("content") or [] + texts = [item.get("text", "") for item in content if item.get("type") == "input_text"] + if texts: + return "\n".join(filter(None, texts)).strip() + return None - message = payload.get("message") - if isinstance(message, str) and message.strip(): - return message.strip() + @classmethod + def _extract_event(cls, entry: dict) -> Optional[Tuple[str, str]]: + """ + Extract a (role, text) event from a JSONL entry. + Role is "user" or "assistant". + """ + user_msg = cls._extract_user_message(entry) + if isinstance(user_msg, str) and user_msg.strip(): + return "user", user_msg.strip() + ai_msg = cls._extract_message(entry) + if isinstance(ai_msg, str) and ai_msg.strip(): + return "assistant", ai_msg.strip() return None + def latest_conversations(self, n: int = 1) -> List[Tuple[str, str]]: + """Get the latest n conversations (question, reply) pairs""" + # Always use _latest_log() to detect newer sessions + log_path = self._latest_log() + if not log_path or not log_path.exists(): + return [] + if n <= 0: + return [] + + tail_bytes = self._env_int("CODEX_LOG_CONV_TAIL_BYTES", 1024 * 1024 * 32) + tail_lines = self._env_int("CODEX_LOG_CONV_TAIL_LINES", 20000) + lines = self._iter_lines_reverse(log_path, max_bytes=tail_bytes, max_lines=tail_lines) + if not lines: + return [] + + pairs_rev: List[Tuple[str, str]] = [] + pending_reply: Optional[str] = None + + for line in lines: + if not line.startswith("{"): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if pending_reply is None: + ai_msg = self._extract_message(entry) + if ai_msg: + pending_reply = ai_msg + continue + + user_msg = self._extract_user_message(entry) + if user_msg: + pairs_rev.append((user_msg, pending_reply)) + pending_reply = None + if len(pairs_rev) >= n: + break + + pairs = list(reversed(pairs_rev)) + if not pairs: + self._debug(f"No conversations found in tail (bytes={tail_bytes}, lines={tail_lines}) for log: {log_path}") + return pairs + class CodexCommunicator: - """通过 FIFO 与 Codex 桥接器通信,并使用日志读取回复""" + """Communicates with Codex bridge via FIFO and reads replies from logs""" - def __init__(self): + def __init__(self, lazy_init: bool = False): self.session_info = self._load_session_info() if not self.session_info: - raise RuntimeError("❌ 未找到活跃的Codex会话,请先运行 ccb up codex") + raise RuntimeError("❌ No active Codex session found. Run 'ccb up codex' first") self.session_id = self.session_info["session_id"] self.runtime_dir = Path(self.session_info["runtime_dir"]) self.input_fifo = Path(self.session_info["input_fifo"]) self.terminal = self.session_info.get("terminal", os.environ.get("CODEX_TERMINAL", "tmux")) self.pane_id = get_pane_id_from_session(self.session_info) or "" + self.pane_title_marker = self.session_info.get("pane_title_marker") or "" self.backend = get_backend_for_session(self.session_info) self.timeout = int(os.environ.get("CODEX_SYNC_TIMEOUT", "30")) self.marker_prefix = "ask" - preferred_log = self.session_info.get("codex_session_path") - bound_session_id = self.session_info.get("codex_session_id") - self.log_reader = CodexLogReader(log_path=preferred_log, session_id_filter=bound_session_id) self.project_session_file = self.session_info.get("_session_file") - self._prime_log_binding() - - healthy, msg = self._check_session_health() - if not healthy: - raise RuntimeError(f"❌ 会话不健康: {msg}\n提示: 请运行 ccb up codex 启动新会话") + # Lazy initialization: defer log reader and health check + self._log_reader: Optional[CodexLogReader] = None + self._log_reader_primed = False + if self.terminal == "wezterm" and self.backend and self.pane_title_marker: + resolver = getattr(self.backend, "find_pane_by_title_marker", None) + if callable(resolver): + resolved = resolver(self.pane_title_marker) + if resolved: + self.pane_id = resolved + + if not lazy_init: + self._ensure_log_reader() + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError(f"❌ Session unhealthy: {msg}\nTip: Run 'ccb up codex' to start a new session") + + @property + def log_reader(self) -> CodexLogReader: + """Lazy-load log reader on first access""" + if self._log_reader is None: + self._ensure_log_reader() + return self._log_reader + + def _ensure_log_reader(self) -> None: + """Initialize log reader if not already done""" + if self._log_reader is not None: + return + preferred_log = self.session_info.get("codex_session_path") + bound_session_id = self.session_info.get("codex_session_id") + self._log_reader = CodexLogReader(log_path=preferred_log, session_id_filter=bound_session_id) + if not self._log_reader_primed: + self._prime_log_binding() + self._log_reader_primed = True + + def _find_session_file(self) -> Optional[Path]: + current = Path.cwd() + while current != current.parent: + candidate = current / ".codex-session" + if candidate.exists(): + return candidate + current = current.parent + return None def _load_session_info(self): if "CODEX_SESSION_ID" in os.environ: terminal = os.environ.get("CODEX_TERMINAL", "tmux") - # 根据终端类型获取正确的 pane_id + # Get pane_id based on terminal type if terminal == "wezterm": pane_id = os.environ.get("CODEX_WEZTERM_PANE", "") elif terminal == "iterm2": pane_id = os.environ.get("CODEX_ITERM2_PANE", "") else: pane_id = "" - return { + result = { "session_id": os.environ["CODEX_SESSION_ID"], "runtime_dir": os.environ["CODEX_RUNTIME_DIR"], "input_fifo": os.environ["CODEX_INPUT_FIFO"], @@ -278,13 +687,33 @@ def _load_session_info(self): "pane_id": pane_id, "_session_file": None, } - - project_session = Path.cwd() / ".codex-session" - if not project_session.exists(): + session_file = self._find_session_file() + if session_file: + try: + with open(session_file, "r", encoding="utf-8-sig") as f: + file_data = json.load(f) + if isinstance(file_data, dict): + result["codex_session_path"] = file_data.get("codex_session_path") + result["codex_session_id"] = file_data.get("codex_session_id") + result["_session_file"] = str(session_file) + except Exception: + pass + registry = load_registry_by_session_id(os.environ["CODEX_SESSION_ID"]) + if isinstance(registry, dict): + reg_log = registry.get("codex_session_path") + reg_id = registry.get("codex_session_id") + if reg_log: + result["codex_session_path"] = reg_log + if reg_id: + result["codex_session_id"] = reg_id + return result + + project_session = self._find_session_file() + if not project_session: return None try: - with open(project_session, "r", encoding="utf-8") as f: + with open(project_session, "r", encoding="utf-8-sig") as f: data = json.load(f) if not isinstance(data, dict): @@ -304,7 +733,7 @@ def _load_session_info(self): return None def _prime_log_binding(self) -> None: - """确保在会话启动时尽早绑定日志路径和会话ID""" + """Ensure log path and session ID are bound early at session start""" log_hint = self.log_reader.current_log_path() if not log_hint: return @@ -316,39 +745,58 @@ def _check_session_health(self): def _check_session_health_impl(self, probe_terminal: bool): try: if not self.runtime_dir.exists(): - return False, "运行时目录不存在" + return False, "Runtime directory does not exist" - # WezTerm/iTerm2 模式:没有 tmux wrapper,因此通常不会生成 codex.pid; - # 以 pane 存活作为健康判定(与 Gemini 逻辑一致)。 + # WezTerm/iTerm2 mode: no tmux wrapper, so codex.pid usually not generated; + # use pane liveness as health check (consistent with Gemini logic). if self.terminal in ("wezterm", "iterm2"): if not self.pane_id: - return False, f"未找到 {self.terminal} pane_id" + return False, f"{self.terminal} pane_id not found" + if self.terminal == "wezterm" and self.backend and self.pane_title_marker: + resolver = getattr(self.backend, "find_pane_by_title_marker", None) + if callable(resolver) and (not self.backend.is_alive(self.pane_id)): + resolved = resolver(self.pane_title_marker) + if resolved: + self.pane_id = resolved if probe_terminal and (not self.backend or not self.backend.is_alive(self.pane_id)): - return False, f"{self.terminal} pane 不存在: {self.pane_id}" - return True, "会话正常" + return False, f"{self.terminal} pane does not exist: {self.pane_id}" + return True, "Session healthy" - # tmux 模式:依赖 wrapper 写入 codex.pid 与 FIFO + # tmux mode: relies on wrapper to write codex.pid and FIFO codex_pid_file = self.runtime_dir / "codex.pid" if not codex_pid_file.exists(): - return False, "Codex进程PID文件不存在" + return False, "Codex process PID file not found" with open(codex_pid_file, "r", encoding="utf-8") as f: codex_pid = int(f.read().strip()) try: os.kill(codex_pid, 0) except OSError: - return False, f"Codex进程(PID:{codex_pid})已退出" + return False, f"Codex process (PID:{codex_pid}) has exited" + + bridge_pid_file = self.runtime_dir / "bridge.pid" + if not bridge_pid_file.exists(): + return False, "Bridge process PID file not found" + try: + with bridge_pid_file.open("r", encoding="utf-8") as handle: + bridge_pid = int(handle.read().strip()) + except Exception: + return False, "Failed to read bridge process PID" + try: + os.kill(bridge_pid, 0) + except OSError: + return False, f"Bridge process (PID:{bridge_pid}) has exited" if not self.input_fifo.exists(): - return False, "通信管道不存在" + return False, "Communication pipe does not exist" - return True, "会话正常" + return True, "Session healthy" except Exception as exc: - return False, f"检查失败: {exc}" + return False, f"Health check failed: {exc}" def _send_via_terminal(self, content: str) -> None: if not self.backend or not self.pane_id: - raise RuntimeError("未配置终端会话") + raise RuntimeError("Terminal session not configured") self.backend.send_text(self.pane_id, content) def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: @@ -361,7 +809,7 @@ def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: state = self.log_reader.capture_state() - # tmux 模式优先通过 FIFO 驱动桥接器;WezTerm/iTerm2 模式则直接向 pane 注入文本 + # tmux mode drives bridge via FIFO; WezTerm/iTerm2 mode injects text directly to pane if self.terminal in ("wezterm", "iterm2"): self._send_via_terminal(content) else: @@ -378,29 +826,29 @@ def ask_async(self, question: str) -> bool: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") marker, state = self._send_message(question) log_hint = state.get("log_path") or self.log_reader.current_log_path() self._remember_codex_session(log_hint) - print(f"✅ 已发送到Codex (标记: {marker[:12]}...)") - print("提示: 使用 /cpend 查看最新回复") + print(f"✅ Sent to Codex (marker: {marker[:12]}...)") + print("Tip: Use /cpend to view latest reply") return True except Exception as exc: - print(f"❌ 发送失败: {exc}") + print(f"❌ Send failed: {exc}") return False def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str]: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") - print("🔔 发送问题到Codex...") + print(f"🔔 {t('sending_to', provider='Codex')}", flush=True) marker, state = self._send_message(question) wait_timeout = self.timeout if timeout is None else int(timeout) if wait_timeout == 0: - print("⏳ 等待 Codex 回复 (无超时,Ctrl-C 可中断)...") + print(f"⏳ {t('waiting_for_reply', provider='Codex')}", flush=True) start_time = time.time() last_hint = 0 while True: @@ -411,40 +859,56 @@ def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str log_hint = self.log_reader.current_log_path() self._remember_codex_session(log_hint) if message: - print("🤖 Codex回复:") + print(f"🤖 {t('reply_from', provider='Codex')}") print(message) return message elapsed = int(time.time() - start_time) if elapsed >= last_hint + 30: last_hint = elapsed - print(f"⏳ 仍在等待... ({elapsed}s)") + print(f"⏳ Still waiting... ({elapsed}s)") - print(f"⏳ 等待Codex回复 (超时 {wait_timeout} 秒)...") + print(f"⏳ Waiting for Codex reply (timeout {wait_timeout}s)...") message, new_state = self.log_reader.wait_for_message(state, float(wait_timeout)) log_hint = (new_state or {}).get("log_path") if isinstance(new_state, dict) else None if not log_hint: log_hint = self.log_reader.current_log_path() self._remember_codex_session(log_hint) if message: - print("🤖 Codex回复:") + print(f"🤖 {t('reply_from', provider='Codex')}") print(message) return message - print("⏰ Codex未在限定时间内回复,可稍后执行 /cpend 获取最新答案") + print(f"⏰ {t('timeout_no_reply', provider='Codex')}") return None except Exception as exc: - print(f"❌ 同步询问失败: {exc}") + print(f"❌ Sync ask failed: {exc}") return None - def consume_pending(self, display: bool = True): + def consume_pending(self, display: bool = True, n: int = 1): current_path = self.log_reader.current_log_path() self._remember_codex_session(current_path) + + if n > 1: + conversations = self.log_reader.latest_conversations(n) + if not conversations: + if display: + print(t('no_reply_available', provider='Codex')) + return None + if display: + for i, (question, reply) in enumerate(conversations): + if question: + print(f"Q: {question}") + print(f"A: {reply}") + if i < len(conversations) - 1: + print("---") + return conversations + message = self.log_reader.latest_message() if message: self._remember_codex_session(self.log_reader.current_log_path()) if not message: if display: - print("暂无 Codex 回复") + print(t('no_reply_available', provider='Codex')) return None if display: print(message) @@ -452,7 +916,7 @@ def consume_pending(self, display: bool = True): def ping(self, display: bool = True) -> Tuple[bool, str]: healthy, status = self._check_session_health() - msg = f"✅ Codex连接正常 ({status})" if healthy else f"❌ Codex连接异常: {status}" + msg = f"✅ Codex connection OK ({status})" if healthy else f"❌ Codex connection error: {status}" if display: print(msg) return healthy, msg @@ -494,7 +958,7 @@ def _remember_codex_session(self, log_path: Optional[Path]) -> None: if not project_file.exists(): return try: - with project_file.open("r", encoding="utf-8") as handle: + with project_file.open("r", encoding="utf-8-sig") as handle: data = json.load(handle) except Exception: return @@ -504,6 +968,25 @@ def _remember_codex_session(self, log_path: Optional[Path]) -> None: resume_cmd = f"codex resume {session_id}" if session_id else None updated = False + started_at = data.get("started_at") + if started_at and not data.get("codex_session_path") and not data.get("codex_session_id"): + try: + started_ts = time.mktime(time.strptime(started_at, "%Y-%m-%d %H:%M:%S")) + except Exception: + started_ts = None + if started_ts: + try: + log_mtime = log_path_obj.stat().st_mtime + except OSError: + log_mtime = None + if log_mtime is not None and log_mtime < started_ts: + if os.environ.get("CCB_DEBUG") in ("1", "true", "yes"): + print( + f"[DEBUG] Skip binding log older than session start: {log_path_obj}", + file=sys.stderr, + ) + return + if data.get("codex_session_path") != path_str: data["codex_session_path"] = path_str updated = True @@ -527,9 +1010,27 @@ def _remember_codex_session(self, log_path: Optional[Path]) -> None: with tmp_file.open("w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) os.replace(tmp_file, project_file) - except Exception: + except PermissionError as e: + print(f"⚠️ Cannot update {project_file.name}: {e}", file=sys.stderr) + print(f"💡 Try: sudo chown $USER:$USER {project_file}", file=sys.stderr) if tmp_file.exists(): tmp_file.unlink(missing_ok=True) + except Exception as e: + print(f"⚠️ Failed to update {project_file.name}: {e}", file=sys.stderr) + if tmp_file.exists(): + tmp_file.unlink(missing_ok=True) + + registry_path = registry_path_for_session(self.session_id) + if registry_path.exists(): + ok = upsert_registry({ + "ccb_session_id": self.session_id, + "codex_pane_id": self.pane_id or None, + "codex_session_id": session_id, + "codex_session_path": path_str, + "work_dir": self.session_info.get("work_dir"), + }) + if not ok: + print("⚠️ Failed to update cpend registry", file=sys.stderr) self.session_info["codex_session_path"] = path_str if session_id: @@ -579,13 +1080,14 @@ def _extract_session_id(log_path: Path) -> Optional[str]: def main() -> int: import argparse - parser = argparse.ArgumentParser(description="Codex 通信工具(日志驱动)") - parser.add_argument("question", nargs="*", help="要发送的问题") - parser.add_argument("--wait", "-w", action="store_true", help="同步等待回复") - parser.add_argument("--timeout", type=int, default=30, help="同步超时时间(秒)") - parser.add_argument("--ping", action="store_true", help="测试连通性") - parser.add_argument("--status", action="store_true", help="查看状态") - parser.add_argument("--pending", action="store_true", help="查看待处理回复") + parser = argparse.ArgumentParser(description="Codex communication tool (log-driven)") + parser.add_argument("question", nargs="*", help="Question to send") + parser.add_argument("--wait", "-w", action="store_true", help="Wait for reply synchronously") + parser.add_argument("--timeout", type=int, default=30, help="Sync timeout in seconds") + parser.add_argument("--ping", action="store_true", help="Test connectivity") + parser.add_argument("--status", action="store_true", help="Show status") + parser.add_argument("--pending", nargs="?", const=1, type=int, metavar="N", + help="Show pending reply (optionally last N conversations)") args = parser.parse_args() @@ -596,29 +1098,29 @@ def main() -> int: comm.ping() elif args.status: status = comm.get_status() - print("📊 Codex状态:") + print("📊 Codex status:") for key, value in status.items(): print(f" {key}: {value}") - elif args.pending: - comm.consume_pending() + elif args.pending is not None: + comm.consume_pending(n=args.pending) elif args.question: tokens = list(args.question) if tokens and tokens[0].lower() == "ask": tokens = tokens[1:] question_text = " ".join(tokens).strip() if not question_text: - print("❌ 请提供问题内容") + print("❌ Please provide a question") return 1 if args.wait: comm.ask_sync(question_text, args.timeout) else: comm.ask_async(question_text) else: - print("请提供问题或使用 --ping/--status/--pending 选项") + print("Please provide a question or use --ping/--status/--pending options") return 1 return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ Execution failed: {exc}") return 1 diff --git a/lib/codex_dual_bridge.py b/lib/codex_dual_bridge.py index 563ff8a..d429ee6 100644 --- a/lib/codex_dual_bridge.py +++ b/lib/codex_dual_bridge.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python3 """ -Codex 双窗口桥接器 -负责发送命令到 Codex,支持 tmux 和 WezTerm。 +Codex dual-window bridge +Sends commands to Codex, supports tmux and WezTerm. """ from __future__ import annotations @@ -18,8 +17,19 @@ from terminal import TmuxBackend, WeztermBackend +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return max(0.0, value) + + class TerminalCodexSession: - """通过终端会话向 Codex CLI 注入指令""" + """Inject commands to Codex CLI via terminal session""" def __init__(self, terminal_type: str, pane_id: str): self.terminal_type = terminal_type @@ -33,7 +43,7 @@ def send(self, text: str) -> None: class DualBridge: - """Claude ↔ Codex 桥接主流程""" + """Claude ↔ Codex bridge main process""" def __init__(self, runtime_dir: Path, session_id: str): self.runtime_dir = runtime_dir @@ -47,7 +57,7 @@ def __init__(self, runtime_dir: Path, session_id: str): terminal_type = os.environ.get("CODEX_TERMINAL", "tmux") pane_id = os.environ.get("CODEX_WEZTERM_PANE") if terminal_type == "wezterm" else os.environ.get("CODEX_TMUX_SESSION") if not pane_id: - raise RuntimeError(f"缺少 {'CODEX_WEZTERM_PANE' if terminal_type == 'wezterm' else 'CODEX_TMUX_SESSION'} 环境变量") + raise RuntimeError(f"Missing {'CODEX_WEZTERM_PANE' if terminal_type == 'wezterm' else 'CODEX_TMUX_SESSION'} environment variable") self.codex_session = TerminalCodexSession(terminal_type, pane_id) self._running = True @@ -56,25 +66,34 @@ def __init__(self, runtime_dir: Path, session_id: str): def _handle_signal(self, signum: int, _: Any) -> None: self._running = False - self._log_console(f"⚠️ 收到信号 {signum},准备退出...") + self._log_console(f"⚠️ Received signal {signum}, exiting...") def run(self) -> int: - self._log_console("🔌 Codex桥接器已启动,等待Claude指令...") + self._log_console("🔌 Codex bridge started, waiting for Claude commands...") + idle_sleep = _env_float("CCB_BRIDGE_IDLE_SLEEP", 0.05) + error_backoff_min = _env_float("CCB_BRIDGE_ERROR_BACKOFF_MIN", 0.05) + error_backoff_max = _env_float("CCB_BRIDGE_ERROR_BACKOFF_MAX", 0.2) + error_backoff = max(0.0, min(error_backoff_min, error_backoff_max)) while self._running: try: payload = self._read_request() if payload is None: - time.sleep(0.1) + if idle_sleep: + time.sleep(idle_sleep) continue self._process_request(payload) + error_backoff = max(0.0, min(error_backoff_min, error_backoff_max)) except KeyboardInterrupt: self._running = False except Exception as exc: - self._log_console(f"❌ 处理消息失败: {exc}") + self._log_console(f"❌ Failed to process message: {exc}") self._log_bridge(f"error: {exc}") - time.sleep(0.5) + if error_backoff: + time.sleep(error_backoff) + if error_backoff_max: + error_backoff = min(error_backoff_max, max(error_backoff_min, error_backoff * 2)) - self._log_console("👋 Codex桥接器已退出") + self._log_console("👋 Codex bridge exited") return 0 def _read_request(self) -> Optional[Dict[str, Any]]: @@ -86,7 +105,7 @@ def _read_request(self) -> Optional[Dict[str, Any]]: if not line: return None return json.loads(line) - except json.JSONDecodeError: + except (OSError, json.JSONDecodeError): return None def _process_request(self, payload: Dict[str, Any]) -> None: @@ -100,7 +119,7 @@ def _process_request(self, payload: Dict[str, Any]) -> None: try: self.codex_session.send(content) except Exception as exc: - msg = f"❌ 发送至 Codex 失败: {exc}" + msg = f"❌ Failed to send to Codex: {exc}" self._append_history("codex", msg, marker) self._log_console(msg) @@ -116,7 +135,7 @@ def _append_history(self, role: str, content: str, marker: str) -> None: json.dump(entry, handle, ensure_ascii=False) handle.write("\n") except Exception as exc: - self._log_console(f"⚠️ 写入历史失败: {exc}") + self._log_console(f"⚠️ Failed to write history: {exc}") def _log_bridge(self, message: str) -> None: try: @@ -139,9 +158,9 @@ def _log_console(message: str) -> None: def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Claude-Codex 桥接器") - parser.add_argument("--runtime-dir", required=True, help="运行目录") - parser.add_argument("--session-id", required=True, help="会话ID") + parser = argparse.ArgumentParser(description="Claude-Codex bridge") + parser.add_argument("--runtime-dir", required=True, help="Runtime directory") + parser.add_argument("--session-id", required=True, help="Session ID") return parser.parse_args() diff --git a/lib/compat.py b/lib/compat.py new file mode 100644 index 0000000..50f17af --- /dev/null +++ b/lib/compat.py @@ -0,0 +1,9 @@ +"""Windows compatibility utilities""" +import sys + +def setup_windows_encoding(): + """Configure UTF-8 encoding for Windows console""" + if sys.platform == "win32": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') diff --git a/lib/env_utils.py b/lib/env_utils.py new file mode 100644 index 0000000..1be5076 --- /dev/null +++ b/lib/env_utils.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import os + + +def env_bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None or raw == "": + return default + v = raw.strip().lower() + if v in ("0", "false", "no", "off"): + return False + if v in ("1", "true", "yes", "on"): + return True + return default + + +def env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw == "": + return default + try: + return int(raw.strip()) + except (ValueError, TypeError): + return default diff --git a/lib/gaskd_daemon.py b/lib/gaskd_daemon.py new file mode 100644 index 0000000..89e85a0 --- /dev/null +++ b/lib/gaskd_daemon.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from worker_pool import BaseSessionWorker, PerSessionWorkerPool + +from gaskd_protocol import ( + GaskdRequest, + GaskdResult, + extract_reply_for_req, + is_done_text, + make_req_id, + wrap_gemini_prompt, +) +from gaskd_session import compute_session_key, load_project_session +from gemini_comm import GeminiLogReader +from terminal import get_backend_for_session +from askd_runtime import state_file_path, log_path, write_log, random_token +import askd_rpc +from askd_server import AskDaemonServer +from providers import GASKD_SPEC + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _write_log(line: str) -> None: + write_log(log_path(GASKD_SPEC.log_file_name), line) + + +def _is_cancel_text(text: str) -> bool: + s = (text or "").strip().lower() + if not s: + return False + # Observed in Gemini session JSON: {"type":"info","content":"Request cancelled."} + if "request cancelled" in s or "request canceled" in s: + return True + return False + + +def _read_session_messages(session_path: Path) -> Optional[list[dict]]: + # Gemini session JSON may be written in-place; retry briefly on JSONDecodeError. + for attempt in range(10): + try: + with session_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + messages = data.get("messages", []) if isinstance(data, dict) else [] + return messages if isinstance(messages, list) else [] + except json.JSONDecodeError: + if attempt < 9: + time.sleep(0.05) + continue + return None + except OSError: + return None + except Exception: + return None + + +def _cancel_applies_to_req(messages: list[dict], cancel_index: int, req_id: str) -> bool: + # The info message itself doesn't include req_id; match it to the nearest preceding user prompt. + needle = f"CCB_REQ_ID: {req_id}" + for j in range(cancel_index - 1, -1, -1): + msg = messages[j] + if not isinstance(msg, dict): + continue + if msg.get("type") != "user": + continue + content = msg.get("content") + if not isinstance(content, str): + content = str(content or "") + return needle in content + return False + + +def _detect_request_cancelled(session_path: Path, *, from_index: int, req_id: str) -> bool: + if from_index < 0: + from_index = 0 + messages = _read_session_messages(session_path) + if messages is None: + return False + for i in range(min(from_index, len(messages)), len(messages)): + msg = messages[i] + if not isinstance(msg, dict): + continue + if msg.get("type") != "info": + continue + content = msg.get("content") + if not isinstance(content, str): + content = str(content or "") + if not _is_cancel_text(content): + continue + if _cancel_applies_to_req(messages, i, req_id): + return True + return False + + +@dataclass +class _QueuedTask: + request: GaskdRequest + created_ms: int + req_id: str + done_event: threading.Event + result: Optional[GaskdResult] = None + + +class _SessionWorker(BaseSessionWorker[_QueuedTask, GaskdResult]): + def _handle_exception(self, exc: Exception, task: _QueuedTask) -> GaskdResult: + _write_log(f"[ERROR] session={self.session_key} req_id={task.req_id} {exc}") + return GaskdResult( + exit_code=1, + reply=str(exc), + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + def _handle_task(self, task: _QueuedTask) -> GaskdResult: + started_ms = _now_ms() + req = task.request + work_dir = Path(req.work_dir) + _write_log(f"[INFO] start session={self.session_key} req_id={task.req_id} work_dir={req.work_dir}") + + session = load_project_session(work_dir) + if not session: + return GaskdResult( + exit_code=1, + reply="❌ No active Gemini session found for work_dir. Run 'ccb up gemini' in that project first.", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + ok, pane_or_err = session.ensure_pane() + if not ok: + return GaskdResult( + exit_code=1, + reply=f"❌ Session pane not available: {pane_or_err}", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + pane_id = pane_or_err + + backend = get_backend_for_session(session.data) + if not backend: + return GaskdResult( + exit_code=1, + reply="❌ Terminal backend not available", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + log_reader = GeminiLogReader(work_dir=Path(session.work_dir)) + if session.gemini_session_path: + try: + log_reader.set_preferred_session(Path(session.gemini_session_path)) + except Exception: + pass + state = log_reader.capture_state() + + prompt = wrap_gemini_prompt(req.message, task.req_id) + backend.send_text(pane_id, prompt) + + deadline = time.time() + float(req.timeout_s) + done_seen = False + done_ms: int | None = None + latest_reply = "" + + pane_check_interval = float(os.environ.get("CCB_GASKD_PANE_CHECK_INTERVAL", "2.0") or "2.0") + last_pane_check = time.time() + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + + if time.time() - last_pane_check >= pane_check_interval: + try: + alive = bool(backend.is_alive(pane_id)) + except Exception: + alive = False + if not alive: + _write_log(f"[ERROR] Pane {pane_id} died during request session={self.session_key} req_id={task.req_id}") + return GaskdResult( + exit_code=1, + reply="❌ Gemini pane died during request", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + last_pane_check = time.time() + + scan_from = state.get("msg_count") + try: + scan_from_i = int(scan_from) if scan_from is not None else 0 + except Exception: + scan_from_i = 0 + + prev_session_path = state.get("session_path") + reply, state = log_reader.wait_for_message(state, min(remaining, 1.0)) + + # Detect user cancellation via Gemini session JSON info message. + try: + current_count = int(state.get("msg_count") or 0) + except Exception: + current_count = 0 + session_path = state.get("session_path") + if isinstance(session_path, Path) and isinstance(prev_session_path, Path) and session_path != prev_session_path: + scan_from_i = 0 + if isinstance(session_path, Path) and current_count > scan_from_i: + if _detect_request_cancelled(session_path, from_index=scan_from_i, req_id=task.req_id): + _write_log(f"[WARN] Gemini request cancelled - skipping task session={self.session_key} req_id={task.req_id}") + return GaskdResult( + exit_code=1, + reply="❌ Gemini request cancelled. Skipping to next task.", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + if not reply: + continue + latest_reply = str(reply) + if is_done_text(latest_reply, task.req_id): + done_seen = True + done_ms = _now_ms() - started_ms + break + + final_reply = extract_reply_for_req(latest_reply, task.req_id) + return GaskdResult( + exit_code=0 if done_seen else 2, + reply=final_reply, + req_id=task.req_id, + session_key=self.session_key, + done_seen=done_seen, + done_ms=done_ms, + ) + + +class _WorkerPool: + def __init__(self): + self._pool = PerSessionWorkerPool[_SessionWorker]() + + def submit(self, request: GaskdRequest) -> _QueuedTask: + req_id = make_req_id() + task = _QueuedTask(request=request, created_ms=_now_ms(), req_id=req_id, done_event=threading.Event()) + + session = load_project_session(Path(request.work_dir)) + session_key = compute_session_key(session) if session else "gemini:unknown" + + worker = self._pool.get_or_create(session_key, _SessionWorker) + worker.enqueue(task) + return task + + +class GaskdServer: + def __init__(self, host: str = "127.0.0.1", port: int = 0, *, state_file: Optional[Path] = None): + self.host = host + self.port = port + self.state_file = state_file or state_file_path(GASKD_SPEC.state_file_name) + self.token = random_token() + self.pool = _WorkerPool() + + def serve_forever(self) -> int: + def _handle_request(msg: dict) -> dict: + try: + req = GaskdRequest( + client_id=str(msg.get("id") or ""), + work_dir=str(msg.get("work_dir") or ""), + timeout_s=float(msg.get("timeout_s") or 300.0), + quiet=bool(msg.get("quiet") or False), + message=str(msg.get("message") or ""), + output_path=str(msg.get("output_path")) if msg.get("output_path") else None, + ) + except Exception as exc: + return {"type": "gask.response", "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": f"Bad request: {exc}"} + + task = self.pool.submit(req) + task.done_event.wait(timeout=req.timeout_s + 5.0) + result = task.result + if not result: + return {"type": "gask.response", "v": 1, "id": req.client_id, "exit_code": 2, "reply": ""} + + return { + "type": "gask.response", + "v": 1, + "id": req.client_id, + "req_id": result.req_id, + "exit_code": result.exit_code, + "reply": result.reply, + "meta": { + "session_key": result.session_key, + "done_seen": result.done_seen, + "done_ms": result.done_ms, + }, + } + + server = AskDaemonServer( + spec=GASKD_SPEC, + host=self.host, + port=self.port, + token=self.token, + state_file=self.state_file, + request_handler=_handle_request, + ) + return server.serve_forever() + + +def read_state(state_file: Optional[Path] = None) -> Optional[dict]: + state_file = state_file or state_file_path(GASKD_SPEC.state_file_name) + return askd_rpc.read_state(state_file) + + +def ping_daemon(timeout_s: float = 0.5, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(GASKD_SPEC.state_file_name) + return askd_rpc.ping_daemon("gask", timeout_s, state_file) + + +def shutdown_daemon(timeout_s: float = 1.0, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(GASKD_SPEC.state_file_name) + return askd_rpc.shutdown_daemon("gask", timeout_s, state_file) diff --git a/lib/gaskd_protocol.py b/lib/gaskd_protocol.py new file mode 100644 index 0000000..abc754e --- /dev/null +++ b/lib/gaskd_protocol.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ccb_protocol import ( + DONE_PREFIX, + REQ_ID_PREFIX, + is_done_text, + make_req_id, + strip_done_text, +) + +ANY_DONE_LINE_RE = re.compile(r"^\s*CCB_DONE:\s*[0-9a-f]{32}\s*$", re.IGNORECASE) + + +def wrap_gemini_prompt(message: str, req_id: str) -> str: + message = (message or "").rstrip() + return ( + f"{REQ_ID_PREFIX} {req_id}\n\n" + f"{message}\n\n" + "IMPORTANT:\n" + "- Reply normally.\n" + "- End your reply with this exact final line (verbatim, on its own line):\n" + f"{DONE_PREFIX} {req_id}\n" + ) + + +def extract_reply_for_req(text: str, req_id: str) -> str: + """ + Extract the reply segment for req_id from a Gemini message. + + Gemini sometimes emits multiple replies in a single assistant message, each ending with its own + `CCB_DONE: ` line. In that case, we want only the segment between the previous done line + (any req_id) and the done line for our req_id. + """ + lines = [ln.rstrip("\n") for ln in (text or "").splitlines()] + if not lines: + return "" + + # Find last done-line index for this req_id (may not be last line if the model misbehaves). + target_re = re.compile(rf"^\s*CCB_DONE:\s*{re.escape(req_id)}\s*$", re.IGNORECASE) + done_idxs = [i for i, ln in enumerate(lines) if ANY_DONE_LINE_RE.match(ln or "")] + target_idxs = [i for i in done_idxs if target_re.match(lines[i] or "")] + + if not target_idxs: + # Fallback: keep existing behavior (strip only if the last line matches). + return strip_done_text(text, req_id) + + target_i = target_idxs[-1] + prev_done_i = -1 + for i in reversed(done_idxs): + if i < target_i: + prev_done_i = i + break + + segment = lines[prev_done_i + 1 : target_i] + # Trim leading/trailing blank lines for nicer output. + while segment and segment[0].strip() == "": + segment = segment[1:] + while segment and segment[-1].strip() == "": + segment = segment[:-1] + return "\n".join(segment).rstrip() + + +@dataclass(frozen=True) +class GaskdRequest: + client_id: str + work_dir: str + timeout_s: float + quiet: bool + message: str + output_path: str | None = None + + +@dataclass(frozen=True) +class GaskdResult: + exit_code: int + reply: str + req_id: str + session_key: str + done_seen: bool + done_ms: int | None = None diff --git a/lib/gaskd_session.py b/lib/gaskd_session.py new file mode 100644 index 0000000..e84d2b8 --- /dev/null +++ b/lib/gaskd_session.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from ccb_config import apply_backend_env +from session_utils import find_project_session_file as _find_project_session_file, safe_write_session +from terminal import get_backend_for_session + +apply_backend_env() + + +def find_project_session_file(work_dir: Path) -> Optional[Path]: + return _find_project_session_file(work_dir, ".gemini-session") + + +def _read_json(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8-sig") + obj = json.loads(raw) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +def _now_str() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class GeminiProjectSession: + session_file: Path + data: dict + + @property + def terminal(self) -> str: + return (self.data.get("terminal") or "tmux").strip() or "tmux" + + @property + def pane_id(self) -> str: + v = self.data.get("pane_id") if self.terminal in ("wezterm", "iterm2") else self.data.get("tmux_session") + return str(v or "").strip() + + @property + def pane_title_marker(self) -> str: + return str(self.data.get("pane_title_marker") or "").strip() + + @property + def gemini_session_id(self) -> str: + return str(self.data.get("gemini_session_id") or "").strip() + + @property + def gemini_session_path(self) -> str: + return str(self.data.get("gemini_session_path") or "").strip() + + @property + def work_dir(self) -> str: + return str(self.data.get("work_dir") or self.session_file.parent) + + def backend(self): + return get_backend_for_session(self.data) + + def ensure_pane(self) -> Tuple[bool, str]: + backend = self.backend() + if not backend: + return False, "Terminal backend not available" + + pane_id = self.pane_id + if pane_id and backend.is_alive(pane_id): + return True, pane_id + + marker = self.pane_title_marker + resolver = getattr(backend, "find_pane_by_title_marker", None) + if marker and callable(resolver): + resolved = resolver(marker) + if resolved: + self.data["pane_id"] = str(resolved) + self.data["updated_at"] = _now_str() + self._write_back() + return True, str(resolved) + + return False, f"Pane not alive: {pane_id}" + + def _write_back(self) -> None: + payload = json.dumps(self.data, ensure_ascii=False, indent=2) + "\n" + ok, _err = safe_write_session(self.session_file, payload) + if not ok: + return + + +def load_project_session(work_dir: Path) -> Optional[GeminiProjectSession]: + session_file = find_project_session_file(work_dir) + if not session_file: + return None + data = _read_json(session_file) + if not data: + return None + return GeminiProjectSession(session_file=session_file, data=data) + + +def compute_session_key(session: GeminiProjectSession) -> str: + marker = session.pane_title_marker + if marker: + return f"gemini_marker:{marker}" + pane = session.pane_id + if pane: + return f"gemini_pane:{pane}" + sid = session.gemini_session_id + if sid: + return f"gemini:{sid}" + return f"gemini_file:{session.session_file}" diff --git a/lib/gemini_comm.py b/lib/gemini_comm.py index 305cfb9..3b62ba8 100755 --- a/lib/gemini_comm.py +++ b/lib/gemini_comm.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python3 """ -Gemini 通信模块 -支持 tmux 和 WezTerm 终端发送请求,从 ~/.gemini/tmp//chats/session-*.json 读取回复 +Gemini communication module +Supports tmux and WezTerm terminals, reads replies from ~/.gemini/tmp//chats/session-*.json """ from __future__ import annotations @@ -9,20 +8,25 @@ import hashlib import json import os +import sys import time from pathlib import Path -from typing import Optional, Tuple, Dict, Any +from typing import Optional, Tuple, Dict, Any, List from terminal import get_backend_for_session, get_pane_id_from_session +from ccb_config import apply_backend_env +from i18n import t + +apply_backend_env() GEMINI_ROOT = Path(os.environ.get("GEMINI_ROOT") or (Path.home() / ".gemini" / "tmp")).expanduser() def _get_project_hash(work_dir: Optional[Path] = None) -> str: - """计算项目目录的哈希值(与 gemini-cli 的 Storage.getFilePathHash 一致)""" + """Calculate project directory hash (consistent with gemini-cli's Storage.getFilePathHash)""" path = work_dir or Path.cwd() - # gemini-cli 使用的是 Node.js 的 path.resolve()(不会 realpath 解析符号链接), - # 因此这里使用 absolute() 而不是 resolve(),避免在 WSL/Windows 场景下 hash 不一致。 + # 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()) except Exception: @@ -31,7 +35,7 @@ def _get_project_hash(work_dir: Optional[Path] = None) -> str: class GeminiLogReader: - """读取 ~/.gemini/tmp//chats 内的 Gemini 会话文件""" + """Reads Gemini session files from ~/.gemini/tmp//chats""" def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): self.root = Path(root).expanduser() @@ -52,12 +56,22 @@ def __init__(self, root: Path = GEMINI_ROOT, work_dir: Optional[Path] = None): force = 1.0 self._force_read_interval = min(5.0, max(0.2, force)) + @staticmethod + def _debug_enabled() -> bool: + return os.environ.get("CCB_DEBUG") in ("1", "true", "yes") or os.environ.get("GPEND_DEBUG") in ("1", "true", "yes") + + @classmethod + def _debug(cls, message: str) -> None: + if not cls._debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + def _chats_dir(self) -> Optional[Path]: chats = self.root / self._project_hash / "chats" return chats if chats.exists() else None def _scan_latest_session_any_project(self) -> Optional[Path]: - """在所有 projectHash 下扫描最新 session 文件(用于 Windows/WSL 路径哈希不一致的兜底)""" + """Scan latest session across all projectHash (fallback for Windows/WSL path hash mismatch)""" if not self.root.exists(): return None try: @@ -85,23 +99,46 @@ def _scan_latest_session(self) -> Optional[Path]: if sessions: return sessions[-1] - # fallback: projectHash 可能因路径规范化差异(Windows/WSL、符号链接等)而不匹配 - return self._scan_latest_session_any_project() + return None def _latest_session(self) -> Optional[Path]: - if self._preferred_session and self._preferred_session.exists(): - return self._preferred_session - latest = self._scan_latest_session() - if latest: - self._preferred_session = latest - try: - # 若是 fallback 扫描到的 session,则反向绑定 projectHash,后续避免全量扫描 - project_hash = latest.parent.parent.name - if project_hash: - self._project_hash = project_hash - except Exception: - pass - return latest + preferred = self._preferred_session + # Always scan to find the latest session by mtime + scanned = self._scan_latest_session() + + # Compare preferred vs scanned by mtime - use whichever is newer + if preferred and preferred.exists(): + if scanned and scanned.exists(): + try: + pref_mtime = preferred.stat().st_mtime + scan_mtime = scanned.stat().st_mtime + if scan_mtime > pref_mtime: + self._debug(f"Scanned session newer: {scanned} ({scan_mtime}) > {preferred} ({pref_mtime})") + self._preferred_session = scanned + return scanned + except OSError: + pass + self._debug(f"Using preferred session: {preferred}") + return preferred + + if scanned: + self._preferred_session = scanned + self._debug(f"Scan found: {scanned}") + return scanned + # Fallback: Windows/WSL path hash mismatch can cause per-project scan to miss sessions. + if os.environ.get("GEMINI_DISABLE_ANY_PROJECT_SCAN") not in ("1", "true", "yes"): + any_latest = self._scan_latest_session_any_project() + if any_latest: + self._preferred_session = any_latest + try: + project_hash = any_latest.parent.parent.name + if project_hash: + self._project_hash = project_hash + except Exception: + pass + self._debug(f"Fallback scan (any project) found: {any_latest}") + return any_latest + return None def set_preferred_session(self, session_path: Optional[Path]) -> None: if not session_path: @@ -117,7 +154,7 @@ def current_session_path(self) -> Optional[Path]: return self._latest_session() def capture_state(self) -> Dict[str, Any]: - """记录当前会话文件和消息数量""" + """Record current session file and message count""" session = self._latest_session() msg_count = 0 mtime = 0.0 @@ -126,20 +163,39 @@ def capture_state(self) -> Dict[str, Any]: last_gemini_id: Optional[str] = None last_gemini_hash: Optional[str] = None if session and session.exists(): + data: Optional[dict] = None try: stat = session.stat() mtime = stat.st_mtime mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) size = stat.st_size - with session.open("r", encoding="utf-8") as f: - data = json.load(f) + except OSError: + stat = None + + # The session JSON may be written in-place; retry briefly to avoid transient JSONDecodeError. + for attempt in range(10): + try: + with session.open("r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + data = loaded + break + except json.JSONDecodeError: + if attempt < 9: + time.sleep(min(self._poll_interval, 0.05)) + continue + except OSError: + break + + if data is None: + # Unknown baseline (parse failed). Let the wait loop establish a stable baseline first. + msg_count = -1 + else: msg_count = len(data.get("messages", [])) last = self._extract_last_gemini(data) if last: last_gemini_id, content = last last_gemini_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() - except (OSError, json.JSONDecodeError): - pass return { "session_path": session, "msg_count": msg_count, @@ -151,15 +207,15 @@ def capture_state(self) -> Dict[str, Any]: } def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: - """阻塞等待新的 Gemini 回复""" + """Block and wait for new Gemini reply""" return self._read_since(state, timeout, block=True) def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: - """非阻塞读取回复""" + """Non-blocking read reply""" return self._read_since(state, timeout=0.0, block=False) def latest_message(self) -> Optional[str]: - """直接获取最新一条 Gemini 回复""" + """Get the latest Gemini reply directly""" session = self._latest_session() if not session or not session.exists(): return None @@ -174,9 +230,41 @@ def latest_message(self) -> Optional[str]: pass return None + def latest_conversations(self, n: int = 1) -> List[Tuple[str, str]]: + """Get the latest n conversations (question, reply) pairs""" + session = self._latest_session() + if not session or not session.exists(): + return [] + try: + with session.open("r", encoding="utf-8") as f: + data = json.load(f) + messages = data.get("messages", []) + except (OSError, json.JSONDecodeError): + return [] + + conversations: List[Tuple[str, str]] = [] + pending_question: Optional[str] = None + + for msg in messages: + msg_type = msg.get("type") + content = msg.get("content", "") + if not isinstance(content, str): + content = str(content) + content = content.strip() + + if msg_type == "user": + pending_question = content + elif msg_type == "gemini" and content: + question = pending_question or "" + conversations.append((question, content)) + pending_question = None + + return conversations[-n:] if len(conversations) > n else conversations + def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: deadline = time.time() + timeout prev_count = state.get("msg_count", 0) + unknown_baseline = isinstance(prev_count, int) and prev_count < 0 prev_mtime = state.get("mtime", 0.0) prev_mtime_ns = state.get("mtime_ns") if prev_mtime_ns is None: @@ -185,18 +273,18 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup prev_session = state.get("session_path") prev_last_gemini_id = state.get("last_gemini_id") prev_last_gemini_hash = state.get("last_gemini_hash") - # 允许短 timeout 场景下也能扫描到新 session 文件(gask-w 默认 1s/次) + # Allow short timeout to scan new session files (gask-w defaults 1s/poll) rescan_interval = min(2.0, max(0.2, timeout / 2.0)) last_rescan = time.time() last_forced_read = time.time() while True: - # 定期重新扫描,检测是否有新会话文件 + # Periodically rescan to detect new session files if time.time() - last_rescan >= rescan_interval: latest = self._scan_latest_session() if latest and latest != self._preferred_session: self._preferred_session = latest - # 新会话文件,重置计数 + # New session file, reset counters if latest != prev_session: prev_count = 0 prev_mtime = 0.0 @@ -226,8 +314,8 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup current_mtime = stat.st_mtime current_mtime_ns = getattr(stat, "st_mtime_ns", int(current_mtime * 1_000_000_000)) current_size = stat.st_size - # Windows/WSL 场景下文件 mtime 可能是秒级精度,单靠 mtime 会漏掉快速写入的更新; - # 因此同时用文件大小作为变化信号。 + # On Windows/WSL, mtime may have second-level precision, which can miss rapid writes. + # Use file size as additional change signal. if block and current_mtime_ns <= prev_mtime_ns and current_size == prev_size: if time.time() - last_forced_read < self._force_read_interval: time.sleep(self._poll_interval) @@ -250,23 +338,99 @@ def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tup messages = data.get("messages", []) current_count = len(messages) + if unknown_baseline: + # If capture_state couldn't parse the JSON (transient in-place writes), the wait + # loop may see a fully-written reply in the first successful read. If we treat + # that read as a "baseline" we can miss the reply forever. + last_msg = messages[-1] if messages else None + if isinstance(last_msg, dict): + last_type = last_msg.get("type") + last_content = (last_msg.get("content") or "").strip() + else: + last_type = None + last_content = "" + + # Only fast-path when the file has changed since the baseline stat and the + # latest message is a non-empty Gemini reply. + if ( + last_type == "gemini" + and last_content + and (current_mtime_ns > prev_mtime_ns or current_size != prev_size) + ): + msg_id = last_msg.get("id") if isinstance(last_msg, dict) else None + content_hash = hashlib.sha256(last_content.encode("utf-8")).hexdigest() + return last_content, { + "session_path": session, + "msg_count": current_count, + "mtime": current_mtime, + "mtime_ns": current_mtime_ns, + "size": current_size, + "last_gemini_id": msg_id, + "last_gemini_hash": content_hash, + } + + prev_mtime = current_mtime + prev_mtime_ns = current_mtime_ns + prev_size = current_size + prev_count = current_count + last = self._extract_last_gemini(data) + if last: + prev_last_gemini_id, content = last + prev_last_gemini_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() if content else None + unknown_baseline = False + if not block: + return None, { + "session_path": session, + "msg_count": prev_count, + "mtime": prev_mtime, + "mtime_ns": prev_mtime_ns, + "size": prev_size, + "last_gemini_id": prev_last_gemini_id, + "last_gemini_hash": prev_last_gemini_hash, + } + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, { + "session_path": session, + "msg_count": prev_count, + "mtime": prev_mtime, + "mtime_ns": prev_mtime_ns, + "size": prev_size, + "last_gemini_id": prev_last_gemini_id, + "last_gemini_hash": prev_last_gemini_hash, + } + continue + if current_count > prev_count: + # Find the LAST gemini message with content (not the first) + # to avoid returning intermediate status messages + last_gemini_content = None + last_gemini_id = None + last_gemini_hash = None for msg in messages[prev_count:]: if msg.get("type") == "gemini": content = msg.get("content", "").strip() if content: - new_state = { - "session_path": session, - "msg_count": current_count, - "mtime": current_mtime, - "mtime_ns": current_mtime_ns, - "size": current_size, - "last_gemini_id": msg.get("id"), - "last_gemini_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(), - } - return content, new_state + 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: + continue + last_gemini_content = content + last_gemini_id = msg_id + last_gemini_hash = content_hash + if last_gemini_content: + new_state = { + "session_path": session, + "msg_count": current_count, + "mtime": current_mtime, + "mtime_ns": current_mtime_ns, + "size": current_size, + "last_gemini_id": last_gemini_id, + "last_gemini_hash": last_gemini_hash, + } + return last_gemini_content, new_state else: - # 有些版本会先写入空的 gemini 消息,再“原地更新 content”,消息数不变。 + # Some versions write empty gemini message first, then update content in-place. last = self._extract_last_gemini(data) if last: last_id, content = last @@ -337,32 +501,61 @@ def _extract_last_gemini(payload: dict) -> Optional[Tuple[Optional[str], str]]: class GeminiCommunicator: - """通过终端与 Gemini 通信,并从会话文件读取回复""" + """Communicate with Gemini via terminal and read replies from session files""" - def __init__(self): + def __init__(self, lazy_init: bool = False): self.session_info = self._load_session_info() if not self.session_info: - raise RuntimeError("❌ 未找到活跃的 Gemini 会话,请先运行 ccb up gemini") + raise RuntimeError("❌ No active Gemini session found, please run ccb up gemini first") self.session_id = self.session_info["session_id"] self.runtime_dir = Path(self.session_info["runtime_dir"]) self.terminal = self.session_info.get("terminal", "tmux") self.pane_id = get_pane_id_from_session(self.session_info) self.timeout = int(os.environ.get("GEMINI_SYNC_TIMEOUT", "60")) - work_dir_hint = self.session_info.get("work_dir") - log_work_dir = Path(work_dir_hint) if isinstance(work_dir_hint, str) and work_dir_hint else None - self.log_reader = GeminiLogReader(work_dir=log_work_dir) - preferred_session = self.session_info.get("gemini_session_path") or self.session_info.get("session_path") - if preferred_session: - self.log_reader.set_preferred_session(Path(str(preferred_session))) + self.marker_prefix = "ask" self.project_session_file = self.session_info.get("_session_file") self.backend = get_backend_for_session(self.session_info) - healthy, msg = self._check_session_health() - if not healthy: - raise RuntimeError(f"❌ 会话不健康: {msg}\n提示: 请运行 ccb up gemini") + # Lazy initialization: defer log reader and health check + self._log_reader: Optional[GeminiLogReader] = None + self._log_reader_primed = False - self._prime_log_binding() + if not lazy_init: + self._ensure_log_reader() + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError(f"❌ Session unhealthy: {msg}\nHint: Please run ccb up gemini") + + @property + def log_reader(self) -> GeminiLogReader: + """Lazy-load log reader on first access""" + if self._log_reader is None: + self._ensure_log_reader() + return self._log_reader + + def _ensure_log_reader(self) -> None: + """Initialize log reader if not already done""" + if self._log_reader is not None: + return + work_dir_hint = self.session_info.get("work_dir") + log_work_dir = Path(work_dir_hint) if isinstance(work_dir_hint, str) and work_dir_hint else None + self._log_reader = GeminiLogReader(work_dir=log_work_dir) + preferred_session = self.session_info.get("gemini_session_path") or self.session_info.get("session_path") + if preferred_session: + self._log_reader.set_preferred_session(Path(str(preferred_session))) + if not self._log_reader_primed: + self._prime_log_binding() + self._log_reader_primed = True + + def _find_session_file(self) -> Optional[Path]: + current = Path.cwd() + while current != current.parent: + candidate = current / ".gemini-session" + if candidate.exists(): + return candidate + current = current.parent + return None def _prime_log_binding(self) -> None: session_path = self.log_reader.current_session_path() @@ -373,14 +566,14 @@ def _prime_log_binding(self) -> None: def _load_session_info(self): if "GEMINI_SESSION_ID" in os.environ: terminal = os.environ.get("GEMINI_TERMINAL", "tmux") - # 根据终端类型获取正确的 pane_id + # Get correct pane_id based on terminal type if terminal == "wezterm": pane_id = os.environ.get("GEMINI_WEZTERM_PANE", "") elif terminal == "iterm2": pane_id = os.environ.get("GEMINI_ITERM2_PANE", "") else: pane_id = "" - return { + result = { "session_id": os.environ["GEMINI_SESSION_ID"], "runtime_dir": os.environ["GEMINI_RUNTIME_DIR"], "terminal": terminal, @@ -388,9 +581,20 @@ def _load_session_info(self): "pane_id": pane_id, "_session_file": None, } - - project_session = Path.cwd() / ".gemini-session" - if not project_session.exists(): + session_file = self._find_session_file() + if session_file: + try: + with open(session_file, "r", encoding="utf-8") as f: + file_data = json.load(f) + if isinstance(file_data, dict): + result["gemini_session_path"] = file_data.get("gemini_session_path") + result["_session_file"] = str(session_file) + except Exception: + pass + return result + + project_session = self._find_session_file() + if not project_session: return None try: @@ -416,49 +620,58 @@ def _check_session_health(self) -> Tuple[bool, str]: def _check_session_health_impl(self, probe_terminal: bool) -> Tuple[bool, str]: try: if not self.runtime_dir.exists(): - return False, "运行时目录不存在" + return False, "Runtime directory not found" if not self.pane_id: - return False, "未找到会话 ID" + return False, "Session ID not found" if probe_terminal and self.backend and not self.backend.is_alive(self.pane_id): - return False, f"{self.terminal} 会话 {self.pane_id} 不存在" - return True, "会话正常" + return False, f"{self.terminal} session {self.pane_id} not found" + return True, "Session OK" except Exception as exc: - return False, f"检查失败: {exc}" + return False, f"Check failed: {exc}" def _send_via_terminal(self, content: str) -> bool: if not self.backend or not self.pane_id: - raise RuntimeError("未配置终端会话") + raise RuntimeError("Terminal session not configured") self.backend.send_text(self.pane_id, content) return True + def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: + marker = self._generate_marker() + state = self.log_reader.capture_state() + self._send_via_terminal(content) + return marker, state + + def _generate_marker(self) -> str: + return f"{self.marker_prefix}-{int(time.time())}-{os.getpid()}" + def ask_async(self, question: str) -> bool: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") self._send_via_terminal(question) - print(f"✅ 已发送到 Gemini") - print("提示: 使用 gpend 查看回复") + print(f"✅ Sent to Gemini") + print("Hint: Use gpend to view reply") return True except Exception as exc: - print(f"❌ 发送失败: {exc}") + print(f"❌ Send failed: {exc}") return False def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str]: try: healthy, status = self._check_session_health_impl(probe_terminal=False) if not healthy: - raise RuntimeError(f"❌ 会话异常: {status}") + raise RuntimeError(f"❌ Session error: {status}") - print("🔔 发送问题到 Gemini...") + print(f"🔔 {t('sending_to', provider='Gemini')}", flush=True) self._send_via_terminal(question) # Capture state after sending to reduce "question → send" latency. state = self.log_reader.capture_state() wait_timeout = self.timeout if timeout is None else int(timeout) if wait_timeout == 0: - print("⏳ 等待 Gemini 回复 (无超时,Ctrl-C 可中断)...") + print(f"⏳ {t('waiting_for_reply', provider='Gemini')}", flush=True) start_time = time.time() last_hint = 0 while True: @@ -468,38 +681,54 @@ def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str if isinstance(session_path, Path): self._remember_gemini_session(session_path) if message: - print("🤖 Gemini 回复:") + print(f"🤖 {t('reply_from', provider='Gemini')}") print(message) return message elapsed = int(time.time() - start_time) if elapsed >= last_hint + 30: last_hint = elapsed - print(f"⏳ 仍在等待... ({elapsed}s)") + print(f"⏳ Still waiting... ({elapsed}s)") - print(f"⏳ 等待 Gemini 回复 (超时 {wait_timeout} 秒)...") + print(f"⏳ Waiting for Gemini reply (timeout {wait_timeout}s)...") message, new_state = self.log_reader.wait_for_message(state, float(wait_timeout)) session_path = (new_state or {}).get("session_path") if isinstance(new_state, dict) else None if isinstance(session_path, Path): self._remember_gemini_session(session_path) if message: - print("🤖 Gemini 回复:") + print(f"🤖 {t('reply_from', provider='Gemini')}") print(message) return message - print("⏰ Gemini 未在限定时间内回复,可稍后执行 gpend 获取答案") + print(f"⏰ {t('timeout_no_reply', provider='Gemini')}") return None except Exception as exc: - print(f"❌ 同步询问失败: {exc}") + print(f"❌ Sync ask failed: {exc}") return None - def consume_pending(self, display: bool = True): + def consume_pending(self, display: bool = True, n: int = 1): session_path = self.log_reader.current_session_path() if isinstance(session_path, Path): self._remember_gemini_session(session_path) + + if n > 1: + conversations = self.log_reader.latest_conversations(n) + if not conversations: + if display: + print(t('no_reply_available', provider='Gemini')) + return None + if display: + for i, (question, reply) in enumerate(conversations): + if question: + print(f"Q: {question}") + print(f"A: {reply}") + if i < len(conversations) - 1: + print("---") + return conversations + message = self.log_reader.latest_message() if not message: if display: - print("暂无 Gemini 回复") + print(t('no_reply_available', provider='Gemini')) return None if display: print(message) @@ -551,7 +780,16 @@ def _remember_gemini_session(self, session_path: Path) -> None: with tmp_file.open("w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) os.replace(tmp_file, project_file) - except Exception: + except PermissionError as e: + print(f"⚠️ Cannot update {project_file.name}: {e}", file=sys.stderr) + print(f"💡 Try: sudo chown $USER:$USER {project_file}", file=sys.stderr) + try: + if tmp_file.exists(): + tmp_file.unlink(missing_ok=True) + except Exception: + pass + except Exception as e: + print(f"⚠️ Failed to update {project_file.name}: {e}", file=sys.stderr) try: if tmp_file.exists(): tmp_file.unlink(missing_ok=True) @@ -560,7 +798,7 @@ def _remember_gemini_session(self, session_path: Path) -> None: def ping(self, display: bool = True) -> Tuple[bool, str]: healthy, status = self._check_session_health() - msg = f"✅ Gemini 连接正常 ({status})" if healthy else f"❌ Gemini 连接异常: {status}" + msg = f"✅ Gemini connection OK ({status})" if healthy else f"❌ Gemini connection error: {status}" if display: print(msg) return healthy, msg @@ -580,13 +818,14 @@ def get_status(self) -> Dict[str, Any]: def main() -> int: import argparse - parser = argparse.ArgumentParser(description="Gemini 通信工具") - parser.add_argument("question", nargs="*", help="要发送的问题") - parser.add_argument("--wait", "-w", action="store_true", help="同步等待回复") - parser.add_argument("--timeout", type=int, default=60, help="同步超时时间(秒)") - parser.add_argument("--ping", action="store_true", help="测试连通性") - parser.add_argument("--status", action="store_true", help="查看状态") - parser.add_argument("--pending", action="store_true", help="查看待处理回复") + parser = argparse.ArgumentParser(description="Gemini communication tool") + parser.add_argument("question", nargs="*", help="Question to send") + parser.add_argument("--wait", "-w", action="store_true", help="Wait for reply synchronously") + parser.add_argument("--timeout", type=int, default=60, help="Sync timeout in seconds") + parser.add_argument("--ping", action="store_true", help="Test connectivity") + parser.add_argument("--status", action="store_true", help="View status") + parser.add_argument("--pending", nargs="?", const=1, type=int, metavar="N", + help="View pending reply (optionally last N conversations)") args = parser.parse_args() @@ -597,26 +836,26 @@ def main() -> int: comm.ping() elif args.status: status = comm.get_status() - print("📊 Gemini 状态:") + print("📊 Gemini status:") for key, value in status.items(): print(f" {key}: {value}") - elif args.pending: - comm.consume_pending() + elif args.pending is not None: + comm.consume_pending(n=args.pending) elif args.question: question_text = " ".join(args.question).strip() if not question_text: - print("❌ 请提供问题内容") + print("❌ Please provide a question") return 1 if args.wait: comm.ask_sync(question_text, args.timeout) else: comm.ask_async(question_text) else: - print("请提供问题或使用 --ping/--status/--pending 选项") + print("Please provide a question or use --ping/--status/--pending") return 1 return 0 except Exception as exc: - print(f"❌ 执行失败: {exc}") + print(f"❌ Execution failed: {exc}") return 1 diff --git a/lib/i18n.py b/lib/i18n.py new file mode 100644 index 0000000..3d567e6 --- /dev/null +++ b/lib/i18n.py @@ -0,0 +1,243 @@ +""" +i18n - Internationalization support for CCB + +Language detection priority: +1. CCB_LANG environment variable (zh/en/auto) +2. System locale (LANG/LC_ALL/LC_MESSAGES) +3. Default to English +""" + +import os +import locale + +_current_lang = None + +MESSAGES = { + "en": { + # Terminal detection + "no_terminal_backend": "No terminal backend detected (WezTerm or tmux)", + "solutions": "Solutions:", + "install_wezterm": "Install WezTerm (recommended): https://wezfurlong.org/wezterm/", + "or_install_tmux": "Or install tmux", + "or_set_ccb_terminal": "Or set CCB_TERMINAL=wezterm and configure CODEX_WEZTERM_BIN", + "tmux_not_installed": "tmux not installed and WezTerm unavailable", + "install_wezterm_or_tmux": "Solution: Install WezTerm (recommended) or tmux", + + # Startup messages + "starting_backend": "Starting {provider} backend ({terminal})...", + "started_backend": "{provider} started ({terminal}: {pane_id})", + "unknown_provider": "Unknown provider: {provider}", + "resuming_session": "Resuming {provider} session: {session_id}...", + "no_history_fresh": "No {provider} history found, starting fresh", + "warmup": "Warmup: {script}", + "warmup_failed": "Warmup failed: {provider}", + + # Claude + "starting_claude": "Starting Claude...", + "resuming_claude": "Resuming Claude session: {session_id}...", + "no_claude_session": "No local Claude session found, starting fresh", + "session_id": "Session ID: {session_id}", + "runtime_dir": "Runtime dir: {runtime_dir}", + "active_backends": "Active backends: {backends}", + "available_commands": "Available commands:", + "codex_commands": "cask/cask-w/caskd/cping/cpend - Codex communication", + "gemini_commands": "gask/gask-w/gping/gpend - Gemini communication", + "executing": "Executing: {cmd}", + "user_interrupted": "User interrupted", + "cleaning_up": "Cleaning up session resources...", + "cleanup_complete": "Cleanup complete", + + # Banner + "banner_title": "Claude Code Bridge {version}", + "banner_date": "{date}", + "banner_backends": "Backends: {backends}", + + # No-claude mode + "backends_started_no_claude": "Backends started (--no-claude mode)", + "switch_to_pane": "Switch to pane:", + "kill_hint": "Kill: ccb kill {providers}", + + # Status + "backend_status": "AI backend status:", + + # Errors + "cannot_write_session": "Cannot write {filename}: {reason}", + "fix_hint": "Fix: {fix}", + "error": "Error", + "execution_failed": "Execution failed: {error}", + "import_failed": "Import failed: {error}", + "module_import_failed": "Module import failed: {error}", + + # Connectivity + "connectivity_test_failed": "{provider} connectivity test failed: {error}", + "no_reply_available": "No {provider} reply available", + + # Commands + "usage": "Usage: {cmd}", + "sending_to": "Sending question to {provider}...", + "waiting_for_reply": "Waiting for {provider} reply (no timeout, Ctrl-C to interrupt)...", + "reply_from": "{provider} reply:", + "timeout_no_reply": "Timeout: no reply from {provider}", + "session_not_found": "No active {provider} session found", + + # Install messages + "install_complete": "Installation complete", + "uninstall_complete": "Uninstall complete", + "python_version_old": "Python version too old: {version}", + "requires_python": "Requires Python 3.10+", + "missing_dependency": "Missing dependency: {dep}", + "detected_env": "Detected {env} environment", + "confirm_continue": "Confirm continue? (y/N)", + "cancelled": "Cancelled", + }, + "zh": { + # Terminal detection + "no_terminal_backend": "未检测到终端后端 (WezTerm 或 tmux)", + "solutions": "解决方案:", + "install_wezterm": "安装 WezTerm (推荐): https://wezfurlong.org/wezterm/", + "or_install_tmux": "或安装 tmux", + "or_set_ccb_terminal": "或设置 CCB_TERMINAL=wezterm 并配置 CODEX_WEZTERM_BIN", + "tmux_not_installed": "tmux 未安装且 WezTerm 不可用", + "install_wezterm_or_tmux": "解决方案:安装 WezTerm (推荐) 或 tmux", + + # Startup messages + "starting_backend": "正在启动 {provider} 后端 ({terminal})...", + "started_backend": "{provider} 已启动 ({terminal}: {pane_id})", + "unknown_provider": "未知提供者: {provider}", + "resuming_session": "正在恢复 {provider} 会话: {session_id}...", + "no_history_fresh": "未找到 {provider} 历史记录,全新启动", + "warmup": "预热: {script}", + "warmup_failed": "预热失败: {provider}", + + # Claude + "starting_claude": "正在启动 Claude...", + "resuming_claude": "正在恢复 Claude 会话: {session_id}...", + "no_claude_session": "未找到本地 Claude 会话,全新启动", + "session_id": "会话 ID: {session_id}", + "runtime_dir": "运行目录: {runtime_dir}", + "active_backends": "活动后端: {backends}", + "available_commands": "可用命令:", + "codex_commands": "cask/cask-w/caskd/cping/cpend - Codex 通信", + "gemini_commands": "gask/gask-w/gping/gpend - Gemini 通信", + "executing": "执行: {cmd}", + "user_interrupted": "用户中断", + "cleaning_up": "正在清理会话资源...", + "cleanup_complete": "清理完成", + + # Banner + "banner_title": "Claude Code Bridge {version}", + "banner_date": "{date}", + "banner_backends": "后端: {backends}", + + # No-claude mode + "backends_started_no_claude": "后端已启动 (--no-claude 模式)", + "switch_to_pane": "切换到面板:", + "kill_hint": "终止: ccb kill {providers}", + + # Status + "backend_status": "AI 后端状态:", + + # Errors + "cannot_write_session": "无法写入 {filename}: {reason}", + "fix_hint": "修复: {fix}", + "error": "错误", + "execution_failed": "执行失败: {error}", + "import_failed": "导入失败: {error}", + "module_import_failed": "模块导入失败: {error}", + + # Connectivity + "connectivity_test_failed": "{provider} 连通性测试失败: {error}", + "no_reply_available": "暂无 {provider} 回复", + + # Commands + "usage": "用法: {cmd}", + "sending_to": "正在发送问题到 {provider}...", + "waiting_for_reply": "等待 {provider} 回复 (无超时,Ctrl-C 中断)...", + "reply_from": "{provider} 回复:", + "timeout_no_reply": "超时: 未收到 {provider} 回复", + "session_not_found": "未找到活动的 {provider} 会话", + + # Install messages + "install_complete": "安装完成", + "uninstall_complete": "卸载完成", + "python_version_old": "Python 版本过旧: {version}", + "requires_python": "需要 Python 3.10+", + "missing_dependency": "缺少依赖: {dep}", + "detected_env": "检测到 {env} 环境", + "confirm_continue": "确认继续?(y/N)", + "cancelled": "已取消", + }, +} + + +def detect_language() -> str: + """Detect language from environment. + + Priority: + 1. CCB_LANG environment variable (zh/en/auto) + 2. System locale + 3. Default to English + """ + ccb_lang = os.environ.get("CCB_LANG", "auto").lower() + + if ccb_lang in ("zh", "cn", "chinese"): + return "zh" + if ccb_lang in ("en", "english"): + return "en" + + # Auto-detect from system locale + try: + lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "") or os.environ.get("LC_MESSAGES", "") + if not lang: + lang, _ = locale.getdefaultlocale() + lang = lang or "" + + lang = lang.lower() + if lang.startswith("zh") or "chinese" in lang: + return "zh" + except Exception: + pass + + return "en" + + +def get_lang() -> str: + """Get current language setting.""" + global _current_lang + if _current_lang is None: + _current_lang = detect_language() + return _current_lang + + +def set_lang(lang: str) -> None: + """Set language explicitly.""" + global _current_lang + if lang in ("zh", "en"): + _current_lang = lang + + +def t(key: str, **kwargs) -> str: + """Get translated message by key. + + Args: + key: Message key + **kwargs: Format arguments + + Returns: + Translated and formatted message + """ + lang = get_lang() + messages = MESSAGES.get(lang, MESSAGES["en"]) + + msg = messages.get(key) + if msg is None: + # Fallback to English + msg = MESSAGES["en"].get(key, key) + + if kwargs: + try: + msg = msg.format(**kwargs) + except (KeyError, ValueError): + pass + + return msg diff --git a/lib/oaskd_daemon.py b/lib/oaskd_daemon.py new file mode 100644 index 0000000..efb1b4f --- /dev/null +++ b/lib/oaskd_daemon.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from worker_pool import BaseSessionWorker, PerSessionWorkerPool + +from oaskd_protocol import OaskdRequest, OaskdResult, is_done_text, make_req_id, strip_done_text, wrap_opencode_prompt +from oaskd_session import compute_session_key, load_project_session +from opencode_comm import OpenCodeLogReader +from process_lock import ProviderLock +from terminal import get_backend_for_session +from askd_runtime import state_file_path, log_path, write_log, random_token +from env_utils import env_bool +import askd_rpc +from askd_server import AskDaemonServer +from providers import OASKD_SPEC + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _cancel_detection_enabled(default: bool = False) -> bool: + # Disabled by default for stability: OpenCode cancellation is session-scoped and hard to + # attribute to a specific queued task without false positives. + return env_bool("CCB_OASKD_CANCEL_DETECT", default) + + +def _tail_state_for_session(log_reader: OpenCodeLogReader) -> dict: + # OpenCode reader uses storage files, not an append-only log; a fresh capture_state is enough. + return log_reader.capture_state() + + +@dataclass +class _QueuedTask: + request: OaskdRequest + created_ms: int + req_id: str + done_event: threading.Event + result: Optional[OaskdResult] = None + + +class _SessionWorker(BaseSessionWorker[_QueuedTask, OaskdResult]): + def _handle_exception(self, exc: Exception, task: _QueuedTask) -> OaskdResult: + write_log(log_path(OASKD_SPEC.log_file_name), f"[ERROR] session={self.session_key} req_id={task.req_id} {exc}") + return OaskdResult( + exit_code=1, + reply=str(exc), + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + def _handle_task(self, task: _QueuedTask) -> OaskdResult: + started_ms = _now_ms() + req = task.request + work_dir = Path(req.work_dir) + write_log(log_path(OASKD_SPEC.log_file_name), f"[INFO] start session={self.session_key} req_id={task.req_id} work_dir={req.work_dir}") + + # Cross-process serialization: if another client falls back to direct mode, it uses the same + # per-session ProviderLock ("opencode", cwd=f"session:{session_key}"). Without this, daemon and + # direct-mode requests can interleave in the same OpenCode pane and cause reply mismatches/hangs. + lock_timeout = min(300.0, max(1.0, float(req.timeout_s))) + lock = ProviderLock("opencode", cwd=f"session:{self.session_key}", timeout=lock_timeout) + if not lock.acquire(): + return OaskdResult( + exit_code=1, + reply="❌ Another OpenCode request is in progress (session lock timeout).", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + try: + session = load_project_session(work_dir) + if not session: + return OaskdResult( + exit_code=1, + reply="❌ No active OpenCode session found for work_dir. Run 'ccb up opencode' in that project first.", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + ok, pane_or_err = session.ensure_pane() + if not ok: + return OaskdResult( + exit_code=1, + reply=f"❌ Session pane not available: {pane_or_err}", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + pane_id = pane_or_err + + backend = get_backend_for_session(session.data) + if not backend: + return OaskdResult( + exit_code=1, + reply="❌ Terminal backend not available", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + + log_reader = OpenCodeLogReader(work_dir=Path(session.work_dir), session_id_filter=(session.session_id or None)) + state = _tail_state_for_session(log_reader) + cancel_enabled = _cancel_detection_enabled(False) + session_id = state.get("session_id") if cancel_enabled and isinstance(state.get("session_id"), str) else None + cancel_cursor = log_reader.open_cancel_log_cursor() if cancel_enabled and session_id else None + cancel_since_s = time.time() if cancel_enabled else 0.0 + + prompt = wrap_opencode_prompt(req.message, task.req_id) + backend.send_text(pane_id, prompt) + + deadline = time.time() + float(req.timeout_s) + chunks: list[str] = [] + done_seen = False + done_ms: int | None = None + + pane_check_interval = float(os.environ.get("CCB_OASKD_PANE_CHECK_INTERVAL", "2.0") or "2.0") + last_pane_check = time.time() + + while True: + remaining = deadline - time.time() + if remaining <= 0: + break + + if time.time() - last_pane_check >= pane_check_interval: + try: + alive = bool(backend.is_alive(pane_id)) + except Exception: + alive = False + if not alive: + write_log(log_path(OASKD_SPEC.log_file_name), f"[ERROR] Pane {pane_id} died during request session={self.session_key} req_id={task.req_id}") + return OaskdResult( + exit_code=1, + reply="❌ OpenCode pane died during request", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + last_pane_check = time.time() + + reply, state = log_reader.wait_for_message(state, min(remaining, 1.0)) + + # Detect user cancellation using OpenCode server logs (handles the race where storage isn't updated). + if cancel_enabled and session_id and cancel_cursor is not None: + try: + cancelled_log, cancel_cursor = log_reader.detect_cancel_event_in_logs( + cancel_cursor, session_id=session_id, since_epoch_s=cancel_since_s + ) + if cancelled_log: + write_log(log_path(OASKD_SPEC.log_file_name), + f"[WARN] OpenCode request cancelled (log) - skipping task session={self.session_key} req_id={task.req_id}" + ) + return OaskdResult( + exit_code=1, + reply="❌ OpenCode request cancelled. Skipping to next task.", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + except Exception: + pass + + # Detect user cancellation (OpenCode writes an assistant message with MessageAbortedError). + # + # Important: do NOT advance the caller's state baseline when not cancelled. + # OpenCode may create an assistant message early (streaming), then later mark the SAME message + # as aborted; if we update state.assistant_count here, we'd stop scanning that message. + if cancel_enabled: + try: + cancelled, _new_state = log_reader.detect_cancelled_since(state, req_id=task.req_id) + if cancelled: + write_log(log_path(OASKD_SPEC.log_file_name), + f"[WARN] OpenCode request cancelled - skipping task session={self.session_key} req_id={task.req_id}" + ) + return OaskdResult( + exit_code=1, + reply="❌ OpenCode request cancelled. Skipping to next task.", + req_id=task.req_id, + session_key=self.session_key, + done_seen=False, + done_ms=None, + ) + except Exception: + pass + + if not reply: + continue + chunks.append(reply) + combined = "\n".join(chunks) + if is_done_text(combined, task.req_id): + done_seen = True + done_ms = _now_ms() - started_ms + break + + combined = "\n".join(chunks) + final_reply = strip_done_text(combined, task.req_id) + + return OaskdResult( + exit_code=0 if done_seen else 2, + reply=final_reply, + req_id=task.req_id, + session_key=self.session_key, + done_seen=done_seen, + done_ms=done_ms, + ) + finally: + lock.release() + + +class _WorkerPool: + def __init__(self): + self._pool = PerSessionWorkerPool[_SessionWorker]() + + def submit(self, request: OaskdRequest) -> _QueuedTask: + req_id = make_req_id() + task = _QueuedTask(request=request, created_ms=_now_ms(), req_id=req_id, done_event=threading.Event()) + + session = load_project_session(Path(request.work_dir)) + session_key = compute_session_key(session) if session else "opencode:unknown" + + worker = self._pool.get_or_create(session_key, _SessionWorker) + worker.enqueue(task) + try: + qsize = int(worker._q.qsize()) + except Exception: + qsize = -1 + write_log(log_path(OASKD_SPEC.log_file_name), f"[INFO] enqueued session={session_key} req_id={req_id} qsize={qsize} client_id={request.client_id}") + return task + + +class OaskdServer: + def __init__(self, host: str = "127.0.0.1", port: int = 0, *, state_file: Optional[Path] = None): + self.host = host + self.port = port + self.state_file = state_file or state_file_path(OASKD_SPEC.state_file_name) + self.token = random_token() + self.pool = _WorkerPool() + + def serve_forever(self) -> int: + def _handle_request(msg: dict) -> dict: + try: + req = OaskdRequest( + client_id=str(msg.get("id") or ""), + work_dir=str(msg.get("work_dir") or ""), + timeout_s=float(msg.get("timeout_s") or 300.0), + quiet=bool(msg.get("quiet") or False), + message=str(msg.get("message") or ""), + output_path=str(msg.get("output_path")) if msg.get("output_path") else None, + ) + except Exception as exc: + return {"type": "oask.response", "v": 1, "id": msg.get("id"), "exit_code": 1, "reply": f"Bad request: {exc}"} + + write_log( + log_path(OASKD_SPEC.log_file_name), + f"[INFO] recv client_id={req.client_id} work_dir={req.work_dir} timeout_s={int(req.timeout_s)} msg_len={len(req.message)}", + ) + task = self.pool.submit(req) + task.done_event.wait(timeout=req.timeout_s + 5.0) + result = task.result + if not result: + return {"type": "oask.response", "v": 1, "id": req.client_id, "exit_code": 2, "reply": ""} + + return { + "type": "oask.response", + "v": 1, + "id": req.client_id, + "req_id": result.req_id, + "exit_code": result.exit_code, + "reply": result.reply, + "meta": { + "session_key": result.session_key, + "done_seen": result.done_seen, + "done_ms": result.done_ms, + }, + } + + server = AskDaemonServer( + spec=OASKD_SPEC, + host=self.host, + port=self.port, + token=self.token, + state_file=self.state_file, + request_handler=_handle_request, + request_queue_size=128, + on_stop=self._cleanup_state_file, + ) + return server.serve_forever() + + def _cleanup_state_file(self) -> None: + try: + st = read_state(self.state_file) + except Exception: + st = None + try: + if isinstance(st, dict) and int(st.get("pid") or 0) == os.getpid(): + self.state_file.unlink(missing_ok=True) # py3.8+: missing_ok + except TypeError: + try: + if isinstance(st, dict) and int(st.get("pid") or 0) == os.getpid() and self.state_file.exists(): + self.state_file.unlink() + except Exception: + pass + except Exception: + pass + +def read_state(state_file: Optional[Path] = None) -> Optional[dict]: + state_file = state_file or state_file_path(OASKD_SPEC.state_file_name) + return askd_rpc.read_state(state_file) + + +def ping_daemon(timeout_s: float = 0.5, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(OASKD_SPEC.state_file_name) + return askd_rpc.ping_daemon("oask", timeout_s, state_file) + + +def shutdown_daemon(timeout_s: float = 1.0, state_file: Optional[Path] = None) -> bool: + state_file = state_file or state_file_path(OASKD_SPEC.state_file_name) + return askd_rpc.shutdown_daemon("oask", timeout_s, state_file) diff --git a/lib/oaskd_protocol.py b/lib/oaskd_protocol.py new file mode 100644 index 0000000..d594b83 --- /dev/null +++ b/lib/oaskd_protocol.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from ccb_protocol import ( + DONE_PREFIX, + REQ_ID_PREFIX, + is_done_text, + make_req_id, + strip_done_text, +) + + +def wrap_opencode_prompt(message: str, req_id: str) -> str: + message = (message or "").rstrip() + return ( + f"{REQ_ID_PREFIX} {req_id}\n\n" + f"{message}\n\n" + "IMPORTANT:\n" + "- Reply normally.\n" + "- End your reply with this exact final line (verbatim, on its own line):\n" + f"{DONE_PREFIX} {req_id}\n" + ) + + +@dataclass(frozen=True) +class OaskdRequest: + client_id: str + work_dir: str + timeout_s: float + quiet: bool + message: str + output_path: str | None = None + + +@dataclass(frozen=True) +class OaskdResult: + exit_code: int + reply: str + req_id: str + session_key: str + done_seen: bool + done_ms: int | None = None diff --git a/lib/oaskd_session.py b/lib/oaskd_session.py new file mode 100644 index 0000000..9263235 --- /dev/null +++ b/lib/oaskd_session.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from ccb_config import apply_backend_env +from session_utils import find_project_session_file as _find_project_session_file, safe_write_session +from terminal import get_backend_for_session + +apply_backend_env() + + +def find_project_session_file(work_dir: Path) -> Optional[Path]: + return _find_project_session_file(work_dir, ".opencode-session") + + +def _read_json(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8-sig") + obj = json.loads(raw) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +def _now_str() -> str: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class OpenCodeProjectSession: + session_file: Path + data: dict + + @property + def session_id(self) -> str: + # Current .opencode-session uses "session_id". Keep compatibility with older/alternate keys. + return str(self.data.get("session_id") or self.data.get("opencode_session_id") or "").strip() + + @property + def terminal(self) -> str: + return (self.data.get("terminal") or "tmux").strip() or "tmux" + + @property + def pane_id(self) -> str: + v = self.data.get("pane_id") if self.terminal in ("wezterm", "iterm2") else self.data.get("tmux_session") + return str(v or "").strip() + + @property + def pane_title_marker(self) -> str: + return str(self.data.get("pane_title_marker") or "").strip() + + @property + def opencode_session_id(self) -> str: + # Backwards-compatible alias. + return self.session_id + + @property + def opencode_project_id(self) -> str: + return str(self.data.get("opencode_project_id") or "").strip() + + @property + def work_dir(self) -> str: + return str(self.data.get("work_dir") or self.session_file.parent) + + def backend(self): + return get_backend_for_session(self.data) + + def ensure_pane(self) -> Tuple[bool, str]: + backend = self.backend() + if not backend: + return False, "Terminal backend not available" + + pane_id = self.pane_id + if pane_id and backend.is_alive(pane_id): + return True, pane_id + + marker = self.pane_title_marker + resolver = getattr(backend, "find_pane_by_title_marker", None) + if marker and callable(resolver): + resolved = resolver(marker) + if resolved: + self.data["pane_id"] = str(resolved) + self.data["updated_at"] = _now_str() + self._write_back() + return True, str(resolved) + + return False, f"Pane not alive: {pane_id}" + + def update_opencode_binding(self, *, session_id: Optional[str], project_id: Optional[str]) -> None: + updated = False + if session_id and self.data.get("opencode_session_id") != session_id: + self.data["opencode_session_id"] = session_id + updated = True + if project_id and self.data.get("opencode_project_id") != project_id: + self.data["opencode_project_id"] = project_id + updated = True + if updated: + self.data["updated_at"] = _now_str() + if self.data.get("active") is False: + self.data["active"] = True + self._write_back() + + def _write_back(self) -> None: + payload = json.dumps(self.data, ensure_ascii=False, indent=2) + "\n" + ok, _err = safe_write_session(self.session_file, payload) + if not ok: + # Best-effort: never raise (daemon should continue). + return + + +def load_project_session(work_dir: Path) -> Optional[OpenCodeProjectSession]: + session_file = find_project_session_file(work_dir) + if not session_file: + return None + data = _read_json(session_file) + if not data: + return None + return OpenCodeProjectSession(session_file=session_file, data=data) + + +def compute_session_key(session: OpenCodeProjectSession) -> str: + marker = session.pane_title_marker + if marker: + return f"opencode_marker:{marker}" + pane = session.pane_id + if pane: + return f"opencode_pane:{pane}" + sid = session.session_id + if sid: + return f"opencode:{sid}" + return f"opencode_file:{session.session_file}" diff --git a/lib/opencode_comm.py b/lib/opencode_comm.py new file mode 100644 index 0000000..1eaf0e0 --- /dev/null +++ b/lib/opencode_comm.py @@ -0,0 +1,1120 @@ +""" +OpenCode communication module + +Reads replies from OpenCode storage (~/.local/share/opencode/storage) and sends messages by +injecting text into the OpenCode TUI pane via the configured terminal backend. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ccb_protocol import REQ_ID_PREFIX +from ccb_config import apply_backend_env +from i18n import t +from terminal import get_backend_for_session, get_pane_id_from_session + +apply_backend_env() + +_REQ_ID_RE = re.compile(rf"{re.escape(REQ_ID_PREFIX)}\s*([0-9a-fA-F]{{32}})") + + +def compute_opencode_project_id(work_dir: Path) -> str: + """ + Compute OpenCode projectID for a directory. + + OpenCode's current behavior (for git worktrees) uses the lexicographically smallest + root commit hash from `git rev-list --max-parents=0 --all` as the projectID. + Non-git directories fall back to "global". + """ + try: + cwd = Path(work_dir).expanduser() + except Exception: + cwd = Path.cwd() + + def _find_git_dir(start: Path) -> tuple[Path | None, Path | None]: + """ + Return (git_root_dir, git_dir_path) if a .git entry is found. + + Handles: + - normal repos: /.git/ (directory) + - worktrees: /.git (file containing "gitdir: ") + """ + for candidate in [start, *start.parents]: + git_entry = candidate / ".git" + if not git_entry.exists(): + continue + if git_entry.is_dir(): + return candidate, git_entry + if git_entry.is_file(): + try: + raw = git_entry.read_text(encoding="utf-8", errors="replace").strip() + prefix = "gitdir:" + if raw.lower().startswith(prefix): + gitdir = raw[len(prefix) :].strip() + gitdir_path = Path(gitdir) + if not gitdir_path.is_absolute(): + gitdir_path = (candidate / gitdir_path).resolve() + return candidate, gitdir_path + except Exception: + continue + return None, None + + def _read_cached_project_id(git_dir: Path | None) -> str | None: + if not git_dir: + return None + try: + cache_path = git_dir / "opencode" + if not cache_path.exists(): + return None + cached = cache_path.read_text(encoding="utf-8", errors="replace").strip() + return cached or None + except Exception: + return None + + git_root, git_dir = _find_git_dir(cwd) + cached = _read_cached_project_id(git_dir) + if cached: + return cached + + try: + import subprocess + + if not shutil.which("git"): + return "global" + + proc = subprocess.run( + ["git", "rev-list", "--max-parents=0", "--all"], + cwd=str(git_root or cwd), + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + roots = [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + roots.sort() + return roots[0] if roots else "global" + except Exception: + return "global" + + +def _normalize_path_for_match(value: str) -> str: + s = (value or "").strip() + if os.name == "nt": + # MSYS/Git-Bash style: /c/Users/... -> c:/Users/... + if len(s) >= 4 and s[0] == "/" and s[2] == "/" and s[1].isalpha(): + s = f"{s[1].lower()}:/{s[3:]}" + # WSL-style path string seen on Windows occasionally: /mnt/c/... -> c:/... + m = re.match(r"^/mnt/([A-Za-z])/(.*)$", s) + if m: + s = f"{m.group(1).lower()}:/{m.group(2)}" + + try: + path = Path(s).expanduser() + # OpenCode "directory" seems to come from the launch cwd, so avoid resolve() to prevent + # symlink/WSL mismatch (similar rationale to gemini hashing). + normalized = str(path.absolute()) + except Exception: + normalized = str(value) + normalized = normalized.replace("\\", "/").rstrip("/") + if os.name == "nt": + normalized = normalized.lower() + return normalized + + +def _path_is_same_or_parent(parent: str, child: str) -> bool: + parent = _normalize_path_for_match(parent) + child = _normalize_path_for_match(child) + if parent == child: + return True + if not parent or not child: + return False + if not child.startswith(parent): + return False + # Ensure boundary on path segment + return child == parent or child[len(parent) :].startswith("/") + + +def _is_wsl() -> bool: + if os.environ.get("WSL_INTEROP") or os.environ.get("WSL_DISTRO_NAME"): + return True + try: + return "microsoft" in Path("/proc/version").read_text(encoding="utf-8", errors="ignore").lower() + except Exception: + return False + + +def _default_opencode_storage_root() -> Path: + env = (os.environ.get("OPENCODE_STORAGE_ROOT") or "").strip() + if env: + return Path(env).expanduser() + + # Common defaults + candidates: list[Path] = [] + xdg_data_home = (os.environ.get("XDG_DATA_HOME") or "").strip() + if xdg_data_home: + candidates.append(Path(xdg_data_home) / "opencode" / "storage") + candidates.append(Path.home() / ".local" / "share" / "opencode" / "storage") + + # Windows native (best-effort; OpenCode might not use this, but allow it if present) + localappdata = os.environ.get("LOCALAPPDATA") + if localappdata: + candidates.append(Path(localappdata) / "opencode" / "storage") + appdata = os.environ.get("APPDATA") + if appdata: + candidates.append(Path(appdata) / "opencode" / "storage") + # Windows fallback when env vars are missing. + candidates.append(Path.home() / "AppData" / "Local" / "opencode" / "storage") + candidates.append(Path.home() / "AppData" / "Roaming" / "opencode" / "storage") + + # WSL: OpenCode may run on Windows and store data under C:\Users\\AppData\...\opencode\storage. + # Try common /mnt/c mappings. + if _is_wsl(): + users_root = Path("/mnt/c/Users") + if users_root.exists(): + preferred_names: list[str] = [] + for k in ("WINUSER", "USERNAME", "USER"): + v = (os.environ.get(k) or "").strip() + if v and v not in preferred_names: + preferred_names.append(v) + for name in preferred_names: + candidates.append(users_root / name / "AppData" / "Local" / "opencode" / "storage") + candidates.append(users_root / name / "AppData" / "Roaming" / "opencode" / "storage") + + # If still not found, scan for any matching storage dir and pick the most recently modified. + found: list[Path] = [] + try: + for user_dir in users_root.iterdir(): + if not user_dir.is_dir(): + continue + for p in ( + user_dir / "AppData" / "Local" / "opencode" / "storage", + user_dir / "AppData" / "Roaming" / "opencode" / "storage", + ): + if p.exists(): + found.append(p) + except Exception: + found = [] + if found: + found.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0.0, reverse=True) + candidates.insert(0, found[0]) + + for candidate in candidates: + try: + if candidate.exists(): + return candidate + except Exception: + continue + + # Fallback to Linux default even if it doesn't exist yet (ping/health will report). + return candidates[0] + + +OPENCODE_STORAGE_ROOT = _default_opencode_storage_root() + +def _default_opencode_log_root() -> Path: + env = (os.environ.get("OPENCODE_LOG_ROOT") or "").strip() + if env: + return Path(env).expanduser() + + candidates: list[Path] = [] + xdg_data_home = (os.environ.get("XDG_DATA_HOME") or "").strip() + if xdg_data_home: + candidates.append(Path(xdg_data_home) / "opencode" / "log") + candidates.append(Path.home() / ".local" / "share" / "opencode" / "log") + candidates.append(Path.home() / ".opencode" / "log") + + for candidate in candidates: + try: + if candidate.exists(): + return candidate + except Exception: + continue + + return candidates[0] + + +OPENCODE_LOG_ROOT = _default_opencode_log_root() + + +def _latest_opencode_log_file(root: Path = OPENCODE_LOG_ROOT) -> Path | None: + try: + if not root.exists(): + return None + paths = [p for p in root.glob("*.log") if p.is_file()] + except Exception: + return None + if not paths: + return None + try: + paths.sort(key=lambda p: p.stat().st_mtime, reverse=True) + except Exception: + paths.sort() + return paths[0] + + +def _is_cancel_log_line(line: str, *, session_id: str) -> bool: + if not line: + return False + sid = (session_id or "").strip() + if not sid: + return False + if f"sessionID={sid} cancel" in line: + return True + if f"path=/session/{sid}/abort" in line: + return True + return False + + +def _parse_opencode_log_epoch_s(line: str) -> float | None: + """ + Parse OpenCode log timestamp into epoch seconds (UTC). + + Observed format: "INFO 2026-01-09T12:11:12 +1ms service=..." + """ + try: + parts = (line or "").split() + if len(parts) < 2: + return None + ts = parts[1] + dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + return float(dt.timestamp()) + except Exception: + return None + + +class OpenCodeLogReader: + """ + Reads OpenCode session/message/part JSON files. + + Observed storage layout: + storage/session//ses_*.json + storage/message//msg_*.json + storage/part//prt_*.json + """ + + def __init__( + self, + root: Path = OPENCODE_STORAGE_ROOT, + work_dir: Optional[Path] = None, + project_id: str = "global", + *, + session_id_filter: str | None = None, + ): + self.root = Path(root).expanduser() + self.work_dir = work_dir or Path.cwd() + env_project_id = (os.environ.get("OPENCODE_PROJECT_ID") or "").strip() + explicit_project_id = bool(env_project_id) or ((project_id or "").strip() not in ("", "global")) + self.project_id = (env_project_id or project_id or "global").strip() or "global" + self._session_id_filter = (session_id_filter or "").strip() or None + if not explicit_project_id: + detected = self._detect_project_id_for_workdir() + if detected: + self.project_id = detected + else: + # Fallback for older storage layouts or path-matching issues. + self.project_id = compute_opencode_project_id(self.work_dir) + + try: + poll = float(os.environ.get("OPENCODE_POLL_INTERVAL", "0.05")) + except Exception: + poll = 0.05 + self._poll_interval = min(0.5, max(0.02, poll)) + + try: + force = float(os.environ.get("OPENCODE_FORCE_READ_INTERVAL", "1.0")) + except Exception: + force = 1.0 + self._force_read_interval = min(5.0, max(0.2, force)) + + def _session_dir(self) -> Path: + return self.root / "session" / self.project_id + + def _message_dir(self, session_id: str) -> Path: + # Preferred nested layout: message//*.json + nested = self.root / "message" / session_id + if nested.exists(): + return nested + # Fallback legacy layout: message/*.json + return self.root / "message" + + def _part_dir(self, message_id: str) -> Path: + nested = self.root / "part" / message_id + if nested.exists(): + return nested + return self.root / "part" + + def _work_dir_candidates(self) -> list[str]: + candidates: list[str] = [] + env_pwd = (os.environ.get("PWD") or "").strip() + if env_pwd: + candidates.append(env_pwd) + candidates.append(str(self.work_dir)) + try: + candidates.append(str(self.work_dir.resolve())) + except Exception: + pass + # Normalize and de-dup + seen: set[str] = set() + out: list[str] = [] + for c in candidates: + norm = _normalize_path_for_match(c) + if norm and norm not in seen: + seen.add(norm) + out.append(norm) + return out + + def _load_json(self, path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _detect_project_id_for_workdir(self) -> Optional[str]: + """ + Auto-detect OpenCode projectID based on storage/project/*.json. + + Without this, using the default "global" project can accidentally bind to an unrelated + session whose directory is a parent of the current cwd, causing reply polling to miss. + """ + projects_dir = self.root / "project" + if not projects_dir.exists(): + return None + + work_candidates = self._work_dir_candidates() + best_id: str | None = None + best_score: tuple[int, int, float] = (-1, -1, -1.0) + + try: + paths = [p for p in projects_dir.glob("*.json") if p.is_file()] + except Exception: + paths = [] + + for path in paths: + payload = self._load_json(path) + + pid = payload.get("id") if isinstance(payload.get("id"), str) and payload.get("id") else path.stem + worktree = payload.get("worktree") + if not isinstance(pid, str) or not pid: + continue + if not isinstance(worktree, str) or not worktree: + continue + + worktree_norm = _normalize_path_for_match(worktree) + if not worktree_norm: + continue + + # Require the project worktree to contain our cwd (avoid picking an arbitrary child project + # when running from a higher-level directory). + if not any(_path_is_same_or_parent(worktree_norm, c) for c in work_candidates): + continue + + updated = (payload.get("time") or {}).get("updated") + try: + updated_i = int(updated) + except Exception: + updated_i = -1 + try: + mtime = path.stat().st_mtime + except Exception: + mtime = 0.0 + + score = (len(worktree_norm), updated_i, mtime) + if score > best_score: + best_id = pid + best_score = score + + return best_id + + def _get_latest_session(self) -> Optional[dict]: + sessions_dir = self._session_dir() + if not sessions_dir.exists(): + return None + + if self._session_id_filter: + try: + for path in sessions_dir.glob("ses_*.json"): + if not path.is_file(): + continue + payload = self._load_json(path) + sid = payload.get("id") + if isinstance(sid, str) and sid == self._session_id_filter: + return {"path": path, "payload": payload} + except Exception: + pass + + candidates = self._work_dir_candidates() + best_match: dict | None = None + best_updated = -1 + best_mtime = -1.0 + best_any: dict | None = None + best_any_updated = -1 + best_any_mtime = -1.0 + + try: + files = [p for p in sessions_dir.glob("ses_*.json") if p.is_file()] + except Exception: + files = [] + + for path in files: + payload = self._load_json(path) + sid = payload.get("id") + directory = payload.get("directory") + updated = (payload.get("time") or {}).get("updated") + if not isinstance(sid, str) or not sid: + continue + if not isinstance(updated, int): + try: + updated = int(updated) + except Exception: + updated = -1 + try: + mtime = path.stat().st_mtime + except Exception: + mtime = 0.0 + + # Track best-any for fallback + if updated > best_any_updated or (updated == best_any_updated and mtime >= best_any_mtime): + best_any = {"path": path, "payload": payload} + best_any_updated = updated + best_any_mtime = mtime + + if not isinstance(directory, str) or not directory: + continue + session_dir_norm = _normalize_path_for_match(directory) + matched = False + for cwd in candidates: + if _path_is_same_or_parent(session_dir_norm, cwd) or _path_is_same_or_parent(cwd, session_dir_norm): + matched = True + break + if not matched: + continue + + if updated > best_updated or (updated == best_updated and mtime >= best_mtime): + best_match = {"path": path, "payload": payload} + best_updated = updated + best_mtime = mtime + + return best_match or best_any + + def _read_messages(self, session_id: str) -> List[dict]: + message_dir = self._message_dir(session_id) + if not message_dir.exists(): + return [] + messages: list[dict] = [] + try: + paths = [p for p in message_dir.glob("msg_*.json") if p.is_file()] + except Exception: + paths = [] + for path in paths: + payload = self._load_json(path) + if payload.get("sessionID") != session_id: + 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 + + messages.sort(key=_key) + return messages + + def _read_parts(self, message_id: str) -> List[dict]: + part_dir = self._part_dir(message_id) + if not part_dir.exists(): + return [] + parts: list[dict] = [] + try: + paths = [p for p in part_dir.glob("prt_*.json") if p.is_file()] + except Exception: + paths = [] + for path in paths: + payload = self._load_json(path) + if payload.get("messageID") != message_id: + continue + payload["_path"] = str(path) + parts.append(payload) + + 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 + + parts.sort(key=_key) + return parts + + @staticmethod + def _extract_text(parts: List[dict], allow_reasoning_fallback: bool = True) -> str: + def _collect(types: set[str]) -> str: + out: list[str] = [] + for part in parts: + if part.get("type") not in types: + continue + text = part.get("text") + if isinstance(text, str) and text: + out.append(text) + return "".join(out).strip() + + # Prefer final visible content when present. + text = _collect({"text"}) + if text: + return text + + # Fallback: some OpenCode runs only emit reasoning parts without a separate "text" part. + if allow_reasoning_fallback: + return _collect({"reasoning"}) + return "" + + 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} + + payload = session_entry.get("payload") or {} + session_id = payload.get("id") if isinstance(payload.get("id"), str) else None + updated = (payload.get("time") or {}).get("updated") + try: + updated_i = int(updated) + except Exception: + updated_i = -1 + + assistant_count = 0 + last_assistant_id: str | None = None + last_completed: int | None = None + + if session_id: + messages = self._read_messages(session_id) + for msg in messages: + if msg.get("role") == "assistant": + assistant_count += 1 + mid = msg.get("id") + if isinstance(mid, str): + last_assistant_id = mid + completed = (msg.get("time") or {}).get("completed") + try: + last_completed = int(completed) if completed is not None else None + except Exception: + last_completed = None + + return { + "session_path": session_entry.get("path"), + "session_id": session_id, + "session_updated": updated_i, + "assistant_count": assistant_count, + "last_assistant_id": last_assistant_id, + "last_assistant_completed": last_completed, + } + + def _find_new_assistant_reply(self, session_id: str, state: Dict[str, Any]) -> Optional[str]: + prev_count = int(state.get("assistant_count") or 0) + prev_last = state.get("last_assistant_id") + prev_completed = state.get("last_assistant_completed") + + 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 + + latest = assistants[-1] + latest_id = latest.get("id") + completed = (latest.get("time") or {}).get("completed") + try: + completed_i = int(completed) if completed is not None else None + except Exception: + completed_i = None + + # If assistant is still streaming, wait (prefer completed reply). + if completed_i is None: + # 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) + 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): + completed_i = int(time.time() * 1000) + else: + return None # Still streaming, wait + + # 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 + + 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) + return text or None + + def _read_since(self, state: Dict[str, Any], timeout: float, block: bool) -> Tuple[Optional[str], Dict[str, Any]]: + deadline = time.time() + timeout + last_forced_read = time.time() + + session_id = state.get("session_id") + if not isinstance(session_id, str) or not session_id: + session_id = None + + while True: + session_entry = self._get_latest_session() + if not session_entry: + if not block: + return None, state + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, state + continue + + 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 + elif not session_id: + session_id = current_session_id + + if not current_session_id: + if not block: + return None, state + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, state + continue + + updated = (payload.get("time") or {}).get("updated") + try: + updated_i = int(updated) + except Exception: + updated_i = -1 + + prev_updated = int(state.get("session_updated") or -1) + should_scan = updated_i != prev_updated + if block and not should_scan and (time.time() - last_forced_read) >= self._force_read_interval: + should_scan = True + last_forced_read = time.time() + + if should_scan: + reply = self._find_new_assistant_reply(current_session_id, state) + if reply: + new_state = self.capture_state() + # Preserve session binding + if session_id: + new_state["session_id"] = session_id + return reply, new_state + + # Update state baseline even if reply isn't ready yet. + state = dict(state) + state["session_updated"] = updated_i + + if not block: + return None, state + + time.sleep(self._poll_interval) + if time.time() >= deadline: + return None, state + + def wait_for_message(self, state: Dict[str, Any], timeout: float) -> Tuple[Optional[str], Dict[str, Any]]: + return self._read_since(state, timeout, block=True) + + def try_get_message(self, state: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: + return self._read_since(state, timeout=0.0, block=False) + + def latest_message(self) -> Optional[str]: + session_entry = self._get_latest_session() + if not session_entry: + return None + payload = session_entry.get("payload") or {} + session_id = payload.get("id") + if not isinstance(session_id, str) or not session_id: + return None + 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 + latest = assistants[-1] + completed = (latest.get("time") or {}).get("completed") + if completed is None: + return None + parts = self._read_parts(str(latest.get("id"))) + text = self._extract_text(parts) + return text or None + + @staticmethod + def _is_aborted_error(error_obj: object) -> bool: + if not isinstance(error_obj, dict): + return False + name = error_obj.get("name") + if isinstance(name, str) and "aborted" in name.lower(): + return True + data = error_obj.get("data") + if isinstance(data, dict): + msg = data.get("message") + if isinstance(msg, str) and ("aborted" in msg.lower() or "cancel" in msg.lower()): + return True + return False + + @staticmethod + def _extract_req_id_from_text(text: str) -> Optional[str]: + if not text: + return None + m = _REQ_ID_RE.search(text) + return m.group(1).lower() if m else None + + def detect_cancelled_since(self, state: Dict[str, Any], *, req_id: str) -> Tuple[bool, Dict[str, Any]]: + """ + Detect whether the request with `req_id` was cancelled/aborted. + + Observed OpenCode cancellation behavior: + - A new assistant message is written with an `error` like: + {"name":"MessageAbortedError","data":{"message":"The operation was aborted."}} + - That assistant message contains no text parts, so naive reply polling misses it. + """ + req_id = (req_id or "").strip().lower() + if not req_id: + return False, state + + try: + prev_count = int(state.get("assistant_count") or 0) + except Exception: + prev_count = 0 + prev_last = state.get("last_assistant_id") + prev_completed = state.get("last_assistant_completed") + + new_state = self.capture_state() + session_id = new_state.get("session_id") + if not isinstance(session_id, str) or not session_id: + return False, new_state + + messages = self._read_messages(session_id) + assistants = [m for m in messages if m.get("role") == "assistant" and isinstance(m.get("id"), str)] + by_id: dict[str, dict] = {str(m.get("id")): m for m in assistants if isinstance(m.get("id"), str)} + + candidates: list[dict] = [] + if prev_count < len(assistants): + candidates.extend(assistants[prev_count:]) + + # Cancellation can be recorded by updating an existing in-flight assistant message in-place + # (assistant_count unchanged). Always inspect the latest assistant message, and also inspect the + # previous last assistant message when its completed timestamp changed. + last_id = new_state.get("last_assistant_id") + if isinstance(last_id, str) and last_id in by_id and by_id[last_id] not in candidates: + candidates.append(by_id[last_id]) + if ( + isinstance(prev_last, str) + and prev_last in by_id + and prev_last != last_id + and by_id[prev_last] not in candidates + ): + # Include the previous last assistant too (rare session switching / reordering). + candidates.append(by_id[prev_last]) + if ( + isinstance(prev_last, str) + and prev_last in by_id + and prev_last == last_id + and by_id[prev_last] not in candidates + and new_state.get("last_assistant_completed") != prev_completed + ): + candidates.append(by_id[prev_last]) + + if not candidates: + return False, new_state + + for msg in candidates: + if not self._is_aborted_error(msg.get("error")): + continue + parent_id = msg.get("parentID") + if not isinstance(parent_id, str) or not parent_id: + continue + parts = self._read_parts(parent_id) + prompt_text = self._extract_text(parts, allow_reasoning_fallback=True) + prompt_req_id = self._extract_req_id_from_text(prompt_text) + if prompt_req_id and prompt_req_id == req_id: + return True, new_state + + return False, new_state + + def open_cancel_log_cursor(self) -> Dict[str, Any]: + """ + Create a cursor that tails OpenCode's server logs for cancellation/abort events. + + The cursor starts at EOF so only future lines are considered. + """ + path = _latest_opencode_log_file() + if not path: + return {"path": None, "offset": 0} + try: + offset = int(path.stat().st_size) + except Exception: + offset = 0 + return {"path": str(path), "offset": offset, "mtime": float(path.stat().st_mtime) if path.exists() else 0.0} + + def detect_cancel_event_in_logs( + self, cursor: Dict[str, Any], *, session_id: str, since_epoch_s: float + ) -> Tuple[bool, Dict[str, Any]]: + """ + Detect cancellation based on OpenCode log lines. + + This is a fallback for the race where the user interrupts before the prompt/aborted message + is persisted into storage. + """ + if not isinstance(cursor, dict): + cursor = {} + current_path = cursor.get("path") + offset = cursor.get("offset") + cursor_mtime = cursor.get("mtime") + try: + offset_i = int(offset) if offset is not None else 0 + except Exception: + offset_i = 0 + try: + cursor_mtime_f = float(cursor_mtime) if cursor_mtime is not None else 0.0 + except Exception: + cursor_mtime_f = 0.0 + + latest = _latest_opencode_log_file() + if latest is None: + return False, {"path": None, "offset": 0, "mtime": 0.0} + + path = Path(str(current_path)) if isinstance(current_path, str) and current_path else None + if path is None or not path.exists(): + path = latest + offset_i = 0 + cursor_mtime_f = 0.0 + elif latest != path: + # Prefer staying on the same file unless the latest file is clearly newer than our cursor. + try: + latest_mtime = float(latest.stat().st_mtime) + except Exception: + latest_mtime = 0.0 + if latest_mtime > cursor_mtime_f + 0.5: + path = latest + offset_i = 0 + cursor_mtime_f = 0.0 + + try: + size = int(path.stat().st_size) + except Exception: + return False, {"path": str(path), "offset": 0, "mtime": cursor_mtime_f} + + if offset_i < 0 or offset_i > size: + offset_i = 0 + + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + handle.seek(offset_i) + chunk = handle.read() + except Exception: + return False, {"path": str(path), "offset": size, "mtime": cursor_mtime_f} + + try: + new_cursor_mtime = float(path.stat().st_mtime) + except Exception: + new_cursor_mtime = cursor_mtime_f + new_cursor = {"path": str(path), "offset": size, "mtime": new_cursor_mtime} + if not chunk: + return False, new_cursor + + for line in chunk.splitlines(): + if not _is_cancel_log_line(line, session_id=session_id): + continue + ts = _parse_opencode_log_epoch_s(line) + if ts is None: + continue + if ts + 0.1 < float(since_epoch_s): + continue + return True, new_cursor + + return False, new_cursor + + +class OpenCodeCommunicator: + def __init__(self, lazy_init: bool = False): + self.session_info = self._load_session_info() + if not self.session_info: + raise RuntimeError("❌ No active OpenCode session found. Run 'ccb up opencode' first") + + self.session_id = self.session_info["session_id"] + self.runtime_dir = Path(self.session_info["runtime_dir"]) + self.terminal = self.session_info.get("terminal", os.environ.get("OPENCODE_TERMINAL", "tmux")) + self.pane_id = get_pane_id_from_session(self.session_info) or "" + self.backend = get_backend_for_session(self.session_info) + + self.timeout = int(os.environ.get("OPENCODE_SYNC_TIMEOUT", "30")) + self.marker_prefix = "oask" + self.project_session_file = self.session_info.get("_session_file") + + self.log_reader = OpenCodeLogReader() + + if not lazy_init: + healthy, msg = self._check_session_health() + if not healthy: + raise RuntimeError(f"❌ Session unhealthy: {msg}\nTip: Run 'ccb up opencode' to start a new session") + + def _find_session_file(self) -> Optional[Path]: + current = Path.cwd() + while current != current.parent: + candidate = current / ".opencode-session" + if candidate.exists(): + return candidate + current = current.parent + return None + + def _load_session_info(self) -> Optional[dict]: + if "OPENCODE_SESSION_ID" in os.environ: + terminal = os.environ.get("OPENCODE_TERMINAL", "tmux") + if terminal == "wezterm": + pane_id = os.environ.get("OPENCODE_WEZTERM_PANE", "") + elif terminal == "iterm2": + pane_id = os.environ.get("OPENCODE_ITERM2_PANE", "") + else: + pane_id = "" + result = { + "session_id": os.environ["OPENCODE_SESSION_ID"], + "runtime_dir": os.environ["OPENCODE_RUNTIME_DIR"], + "terminal": terminal, + "tmux_session": os.environ.get("OPENCODE_TMUX_SESSION", ""), + "pane_id": pane_id, + "_session_file": None, + } + session_file = self._find_session_file() + if session_file: + try: + with session_file.open("r", encoding="utf-8-sig") as handle: + file_data = json.load(handle) + if isinstance(file_data, dict): + result["opencode_session_path"] = file_data.get("opencode_session_path") + result["_session_file"] = str(session_file) + except Exception: + pass + return result + + project_session = self._find_session_file() + if not project_session: + return None + + try: + with project_session.open("r", encoding="utf-8-sig") as handle: + data = json.load(handle) + + if not isinstance(data, dict) or not data.get("active", False): + return None + + runtime_dir = Path(data.get("runtime_dir", "")) + if not runtime_dir.exists(): + return None + + data["_session_file"] = str(project_session) + return data + except Exception: + return None + + def _check_session_health(self) -> Tuple[bool, str]: + return self._check_session_health_impl(probe_terminal=True) + + def _check_session_health_impl(self, probe_terminal: bool) -> Tuple[bool, str]: + try: + if not self.runtime_dir.exists(): + return False, "Runtime directory not found" + if not self.pane_id: + return False, "Session pane not found" + if probe_terminal and self.backend and not self.backend.is_alive(self.pane_id): + return False, f"{self.terminal} session {self.pane_id} not found" + + # Storage health check (reply reader) + if not OPENCODE_STORAGE_ROOT.exists(): + return False, f"OpenCode storage not found: {OPENCODE_STORAGE_ROOT}" + return True, "Session OK" + except Exception as exc: + return False, f"Check failed: {exc}" + + def ping(self, display: bool = True) -> Tuple[bool, str]: + healthy, status = self._check_session_health() + msg = f"✅ OpenCode connection OK ({status})" if healthy else f"❌ OpenCode connection error: {status}" + if display: + print(msg) + return healthy, msg + + def _send_via_terminal(self, content: str) -> None: + if not self.backend or not self.pane_id: + raise RuntimeError("Terminal session not configured") + self.backend.send_text(self.pane_id, content) + + def _send_message(self, content: str) -> Tuple[str, Dict[str, Any]]: + marker = self._generate_marker() + state = self.log_reader.capture_state() + self._send_via_terminal(content) + return marker, state + + def _generate_marker(self) -> str: + return f"{self.marker_prefix}-{int(time.time())}-{os.getpid()}" + + def ask_async(self, question: str) -> bool: + try: + healthy, status = self._check_session_health_impl(probe_terminal=False) + if not healthy: + raise RuntimeError(f"❌ Session error: {status}") + self._send_via_terminal(question) + print("✅ Sent to OpenCode") + print("Hint: Use opend to view reply") + return True + except Exception as exc: + print(f"❌ Send failed: {exc}") + return False + + def ask_sync(self, question: str, timeout: Optional[int] = None) -> Optional[str]: + try: + healthy, status = self._check_session_health_impl(probe_terminal=False) + if not healthy: + raise RuntimeError(f"❌ Session error: {status}") + + print(f"🔔 {t('sending_to', provider='OpenCode')}", flush=True) + _, state = self._send_message(question) + wait_timeout = self.timeout if timeout is None else int(timeout) + print(f"⏳ Waiting for OpenCode reply (timeout {wait_timeout}s)...") + message, _ = self.log_reader.wait_for_message(state, float(wait_timeout)) + if message: + print(f"🤖 {t('reply_from', provider='OpenCode')}") + print(message) + return message + print(f"⏰ {t('timeout_no_reply', provider='OpenCode')}") + return None + except Exception as exc: + print(f"❌ Sync ask failed: {exc}") + return None diff --git a/lib/pane_registry.py b/lib/pane_registry.py new file mode 100644 index 0000000..b3e4ee8 --- /dev/null +++ b/lib/pane_registry.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from typing import Optional, Dict, Any, Iterable + +from cli_output import atomic_write_text + +REGISTRY_PREFIX = "ccb-session-" +REGISTRY_SUFFIX = ".json" +REGISTRY_TTL_SECONDS = 7 * 24 * 60 * 60 + + +def _debug_enabled() -> bool: + return os.environ.get("CCB_DEBUG") in ("1", "true", "yes") + + +def _debug(message: str) -> None: + if not _debug_enabled(): + return + print(f"[DEBUG] {message}", file=sys.stderr) + + +def _registry_dir() -> Path: + return Path.home() / ".ccb" / "run" + + +def registry_path_for_session(session_id: str) -> Path: + return _registry_dir() / f"{REGISTRY_PREFIX}{session_id}{REGISTRY_SUFFIX}" + + +def _iter_registry_files() -> Iterable[Path]: + registry_dir = _registry_dir() + if not registry_dir.exists(): + return [] + return sorted(registry_dir.glob(f"{REGISTRY_PREFIX}*{REGISTRY_SUFFIX}")) + + +def _coerce_updated_at(value: Any, fallback_path: Optional[Path] = None) -> int: + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + trimmed = value.strip() + if trimmed.isdigit(): + try: + return int(trimmed) + except ValueError: + pass + if fallback_path: + try: + return int(fallback_path.stat().st_mtime) + except OSError: + return 0 + return 0 + + +def _is_stale(updated_at: int, now: Optional[int] = None) -> bool: + if updated_at <= 0: + return True + now_ts = int(time.time()) if now is None else int(now) + return (now_ts - updated_at) > REGISTRY_TTL_SECONDS + + +def _load_registry_file(path: Path) -> Optional[Dict[str, Any]]: + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if isinstance(data, dict): + return data + except Exception as exc: + _debug(f"Failed to read registry {path}: {exc}") + return None + + +def load_registry_by_session_id(session_id: str) -> Optional[Dict[str, Any]]: + if not session_id: + return None + path = registry_path_for_session(session_id) + if not path.exists(): + return None + data = _load_registry_file(path) + if not data: + return None + updated_at = _coerce_updated_at(data.get("updated_at"), path) + if _is_stale(updated_at): + _debug(f"Registry stale for session {session_id}: {path}") + return None + return data + + +def load_registry_by_claude_pane(pane_id: str) -> Optional[Dict[str, Any]]: + if not pane_id: + return None + best: Optional[Dict[str, Any]] = None + best_ts = -1 + for path in _iter_registry_files(): + data = _load_registry_file(path) + if not data: + continue + if data.get("claude_pane_id") != pane_id: + continue + updated_at = _coerce_updated_at(data.get("updated_at"), path) + if _is_stale(updated_at): + _debug(f"Registry stale for pane {pane_id}: {path}") + continue + if updated_at > best_ts: + best = data + best_ts = updated_at + return best + + +def upsert_registry(record: Dict[str, Any]) -> bool: + session_id = record.get("ccb_session_id") + if not session_id: + _debug("Registry update skipped: missing ccb_session_id") + return False + path = registry_path_for_session(str(session_id)) + path.parent.mkdir(parents=True, exist_ok=True) + + data: Dict[str, Any] = {} + if path.exists(): + existing = _load_registry_file(path) + if isinstance(existing, dict): + data.update(existing) + + for key, value in record.items(): + if value is None: + continue + data[key] = value + + data["updated_at"] = int(time.time()) + + try: + atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2)) + return True + except Exception as exc: + _debug(f"Failed to write registry {path}: {exc}") + return False diff --git a/lib/process_lock.py b/lib/process_lock.py new file mode 100644 index 0000000..e70cf25 --- /dev/null +++ b/lib/process_lock.py @@ -0,0 +1,208 @@ +""" +process_lock.py - Per-provider, per-directory file lock to serialize request-response cycles. + +Each provider (codex, gemini, opencode) has its own lock file per working directory, +allowing concurrent use across different directories while ensuring serial access +within the same directory. +""" +from __future__ import annotations + +import hashlib +import os +import sys +import time +from pathlib import Path +from typing import Optional + + +def _is_pid_alive(pid: int) -> bool: + """Check if a process with given PID is still running.""" + if os.name == "nt": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + SYNCHRONIZE = 0x00100000 + handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) + if handle: + kernel32.CloseHandle(handle) + return True + return False + except Exception: + return True # Assume alive if we can't check + else: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +class ProviderLock: + """Per-provider, per-directory file lock to serialize request-response cycles. + + Lock files are stored in ~/.ccb/run/{provider}-{cwd_hash}.lock + """ + + def __init__(self, provider: str, timeout: float = 60.0, cwd: str = None): + """Initialize lock for a specific provider and working directory. + + Args: + provider: One of "codex", "gemini", "opencode" + timeout: Max seconds to wait for lock acquisition + cwd: Working directory for lock scope (defaults to current directory) + """ + self.provider = provider + self.timeout = timeout + self.lock_dir = Path.home() / ".ccb" / "run" + + # Use working directory hash for per-directory locking + if cwd is None: + cwd = os.getcwd() + cwd_hash = hashlib.md5(cwd.encode()).hexdigest()[:8] + self.lock_file = self.lock_dir / f"{provider}-{cwd_hash}.lock" + self._fd: Optional[int] = None + self._acquired = False + + def _try_acquire_once(self) -> bool: + """Attempt to acquire lock once without blocking.""" + try: + if os.name == "nt": + import msvcrt + # Ensure the file has at least 1 byte so region locking is reliable on Windows. + try: + st = os.fstat(self._fd) + if getattr(st, "st_size", 0) < 1: + os.lseek(self._fd, 0, os.SEEK_SET) + os.write(self._fd, b"\0") + except Exception: + pass + msvcrt.locking(self._fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Write PID for debugging and stale lock detection + pid_bytes = f"{os.getpid()}\n".encode() + os.lseek(self._fd, 0, os.SEEK_SET) + os.write(self._fd, pid_bytes) + # Keep file length >= 1 on Windows to avoid invalidating the locked region. + if os.name == "nt": + try: + os.ftruncate(self._fd, max(1, len(pid_bytes))) + except Exception: + pass + else: + os.ftruncate(self._fd, len(pid_bytes)) + self._acquired = True + return True + except (OSError, IOError): + return False + + def _check_stale_lock(self) -> bool: + """Check if current lock holder is dead, allowing us to take over.""" + try: + with open(self.lock_file, "r") as f: + content = f.read().strip() + if content: + pid = int(content) + if not _is_pid_alive(pid): + # Stale lock - remove it + try: + self.lock_file.unlink() + except OSError: + pass + return True + except (OSError, ValueError): + pass + return False + + def try_acquire(self) -> bool: + """Try to acquire lock without blocking. Returns immediately. + + Returns: + True if lock acquired, False if lock is held by another process + """ + self.lock_dir.mkdir(parents=True, exist_ok=True) + self._fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR) + + if self._try_acquire_once(): + return True + + # Check for stale lock + if self._check_stale_lock(): + os.close(self._fd) + self._fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR) + if self._try_acquire_once(): + return True + + # Failed - close fd + os.close(self._fd) + self._fd = None + return False + + def acquire(self) -> bool: + """Acquire the lock, waiting up to timeout seconds. + + Returns: + True if lock acquired, False if timeout + """ + self.lock_dir.mkdir(parents=True, exist_ok=True) + self._fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR) + + deadline = time.time() + self.timeout + stale_checked = False + + while time.time() < deadline: + if self._try_acquire_once(): + return True + + # Check for stale lock once after first failure + if not stale_checked: + stale_checked = True + if self._check_stale_lock(): + # Lock file was stale, reopen and retry + os.close(self._fd) + self._fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR) + if self._try_acquire_once(): + return True + + time.sleep(0.1) + + # Timeout - close fd + if self._fd is not None: + os.close(self._fd) + self._fd = None + return False + + def release(self) -> None: + """Release the lock.""" + if self._fd is not None: + try: + if self._acquired: + if os.name == "nt": + import msvcrt + try: + msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + else: + import fcntl + try: + fcntl.flock(self._fd, fcntl.LOCK_UN) + except OSError: + pass + finally: + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + self._acquired = False + + def __enter__(self) -> "ProviderLock": + if not self.acquire(): + raise TimeoutError(f"Failed to acquire {self.provider} lock after {self.timeout}s") + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.release() diff --git a/lib/providers.py b/lib/providers.py new file mode 100644 index 0000000..03ab16d --- /dev/null +++ b/lib/providers.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ProviderDaemonSpec: + daemon_key: str + protocol_prefix: str + state_file_name: str + log_file_name: str + idle_timeout_env: str + lock_name: str + + +@dataclass +class ProviderClientSpec: + protocol_prefix: str + enabled_env: str + autostart_env_primary: str + autostart_env_legacy: str + state_file_env: str + session_filename: str + daemon_bin_name: str + daemon_module: str + + +CASKD_SPEC = ProviderDaemonSpec( + daemon_key="caskd", + protocol_prefix="cask", + state_file_name="caskd.json", + log_file_name="caskd.log", + idle_timeout_env="CCB_CASKD_IDLE_TIMEOUT_S", + lock_name="caskd", +) + + +GASKD_SPEC = ProviderDaemonSpec( + daemon_key="gaskd", + protocol_prefix="gask", + state_file_name="gaskd.json", + log_file_name="gaskd.log", + idle_timeout_env="CCB_GASKD_IDLE_TIMEOUT_S", + lock_name="gaskd", +) + + +OASKD_SPEC = ProviderDaemonSpec( + daemon_key="oaskd", + protocol_prefix="oask", + state_file_name="oaskd.json", + log_file_name="oaskd.log", + idle_timeout_env="CCB_OASKD_IDLE_TIMEOUT_S", + lock_name="oaskd", +) + + +CASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="cask", + enabled_env="CCB_CASKD", + autostart_env_primary="CCB_CASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_CASKD", + state_file_env="CCB_CASKD_STATE_FILE", + session_filename=".codex-session", + daemon_bin_name="caskd", + daemon_module="caskd_daemon", +) + + +GASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="gask", + enabled_env="CCB_GASKD", + autostart_env_primary="CCB_GASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_GASKD", + state_file_env="CCB_GASKD_STATE_FILE", + session_filename=".gemini-session", + daemon_bin_name="gaskd", + daemon_module="gaskd_daemon", +) + + +OASK_CLIENT_SPEC = ProviderClientSpec( + protocol_prefix="oask", + enabled_env="CCB_OASKD", + autostart_env_primary="CCB_OASKD_AUTOSTART", + autostart_env_legacy="CCB_AUTO_OASKD", + state_file_env="CCB_OASKD_STATE_FILE", + session_filename=".opencode-session", + daemon_bin_name="oaskd", + daemon_module="oaskd_daemon", +) diff --git a/lib/session_utils.py b/lib/session_utils.py new file mode 100644 index 0000000..10cd85e --- /dev/null +++ b/lib/session_utils.py @@ -0,0 +1,129 @@ +""" +session_utils.py - Session file permission check utility +""" +from __future__ import annotations +import os +import stat +from pathlib import Path +from typing import Tuple, Optional + + +def check_session_writable(session_file: Path) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Check if session file is writable + + Returns: + (writable, error_reason, fix_suggestion) + """ + session_file = Path(session_file) + parent = session_file.parent + + # 1. Check if parent directory exists and is accessible + if not parent.exists(): + return False, f"Directory not found: {parent}", f"mkdir -p {parent}" + + if not os.access(parent, os.X_OK): + return False, f"Directory not accessible (missing x permission): {parent}", f"chmod +x {parent}" + + # 2. Check if parent directory is writable + if not os.access(parent, os.W_OK): + return False, f"Directory not writable: {parent}", f"chmod u+w {parent}" + + # 3. If file doesn't exist, directory writable is enough + if not session_file.exists(): + return True, None, None + + # 4. Check if it's a regular file + if session_file.is_symlink(): + target = session_file.resolve() + return False, f"Is symlink pointing to {target}", f"rm -f {session_file}" + + if session_file.is_dir(): + return False, "Is directory, not file", f"rmdir {session_file} or rm -rf {session_file}" + + if not session_file.is_file(): + return False, "Not a regular file", f"rm -f {session_file}" + + # 5. Check file ownership (POSIX only) + if os.name != "nt" and hasattr(os, "getuid"): + try: + file_stat = session_file.stat() + file_uid = getattr(file_stat, "st_uid", None) + current_uid = os.getuid() + + if isinstance(file_uid, int) and file_uid != current_uid: + import pwd + + try: + owner_name = pwd.getpwuid(file_uid).pw_name + except KeyError: + owner_name = str(file_uid) + current_name = pwd.getpwuid(current_uid).pw_name + return ( + False, + f"File owned by {owner_name} (current user: {current_name})", + f"sudo chown {current_name}:{current_name} {session_file}", + ) + except Exception: + pass + + # 6. Check if file is writable + if not os.access(session_file, os.W_OK): + mode = stat.filemode(session_file.stat().st_mode) + return False, f"File not writable (mode: {mode})", f"chmod u+w {session_file}" + + return True, None, None + + +def safe_write_session(session_file: Path, content: str) -> Tuple[bool, Optional[str]]: + """ + Safely write session file, return friendly error on failure + + Returns: + (success, error_message) + """ + session_file = Path(session_file) + + # Pre-check + writable, reason, fix = check_session_writable(session_file) + if not writable: + return False, f"❌ Cannot write {session_file.name}: {reason}\n💡 Fix: {fix}" + + # Attempt atomic write + tmp_file = session_file.with_suffix(".tmp") + try: + tmp_file.write_text(content, encoding="utf-8") + os.replace(tmp_file, session_file) + return True, None + except PermissionError as e: + if tmp_file.exists(): + try: + tmp_file.unlink() + except Exception: + pass + return False, f"❌ Cannot write {session_file.name}: {e}\n💡 Try: rm -f {session_file} then retry" + except Exception as e: + if tmp_file.exists(): + try: + tmp_file.unlink() + except Exception: + pass + return False, f"❌ Write failed: {e}" + + +def print_session_error(msg: str, to_stderr: bool = True) -> None: + """Output session-related error""" + import sys + output = sys.stderr if to_stderr else sys.stdout + print(msg, file=output) + + +def find_project_session_file(work_dir: Path, session_filename: str) -> Optional[Path]: + current = Path(work_dir).resolve() + while True: + candidate = current / session_filename + if candidate.exists(): + return candidate + if current == current.parent: + return None + current = current.parent diff --git a/lib/terminal.py b/lib/terminal.py index 5068d6d..34b9263 100644 --- a/lib/terminal.py +++ b/lib/terminal.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 from __future__ import annotations import json import os @@ -13,6 +12,17 @@ from typing import Optional +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + return max(0.0, value) + + def is_windows() -> bool: return platform.system() == "Windows" @@ -24,18 +34,80 @@ def is_wsl() -> bool: return False +def _choose_wezterm_cli_cwd() -> str | None: + """ + Pick a safe cwd for launching Windows `wezterm.exe` from inside WSL. + + When a Windows binary is launched via WSL interop from a WSL cwd (e.g. /home/...), + Windows may treat the process cwd as a UNC path like \\\\wsl.localhost\\..., + which can confuse WezTerm's WSL relay and produce noisy `chdir(/wsl.localhost/...) failed 2`. + Using a Windows-mounted path like /mnt/c avoids that. + """ + override = (os.environ.get("CCB_WEZTERM_CLI_CWD") or "").strip() + candidates = [override] if override else [] + candidates.extend(["/mnt/c", "/mnt/d", "/mnt"]) + for candidate in candidates: + if not candidate: + continue + try: + p = Path(candidate) + if p.is_dir(): + return str(p) + except Exception: + continue + return None + + +def _extract_wsl_path_from_unc_like_path(raw: str) -> str | None: + """ + Convert UNC-like WSL paths into a WSL-internal absolute path. + + Supports forms commonly seen in Git Bash/MSYS and Windows: + - /wsl.localhost/Ubuntu-24.04/home/user/... + - \\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\... + - /wsl$/Ubuntu-24.04/home/user/... + Returns a POSIX absolute path like: /home/user/... + """ + if not raw: + return None + + m = re.match(r'^(?:[/\\]{1,2})(?:wsl\.localhost|wsl\$)[/\\]([^/\\]+)(.*)$', raw, re.IGNORECASE) + if not m: + return None + remainder = m.group(2).replace("\\", "/") + if not remainder: + return "/" + if not remainder.startswith("/"): + remainder = "/" + remainder + return remainder + + def _load_cached_wezterm_bin() -> str | None: - """读取安装时缓存的 WezTerm 路径""" - config = Path.home() / ".config/ccb/env" - if config.exists(): + """Load cached WezTerm path from installation""" + candidates: list[Path] = [] + xdg = (os.environ.get("XDG_CONFIG_HOME") or "").strip() + if xdg: + candidates.append(Path(xdg) / "ccb" / "env") + if os.name == "nt": + localappdata = (os.environ.get("LOCALAPPDATA") or "").strip() + if localappdata: + candidates.append(Path(localappdata) / "ccb" / "env") + appdata = (os.environ.get("APPDATA") or "").strip() + if appdata: + candidates.append(Path(appdata) / "ccb" / "env") + candidates.append(Path.home() / ".config" / "ccb" / "env") + + for config in candidates: try: - for line in config.read_text().splitlines(): + if not config.exists(): + continue + for line in config.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("CODEX_WEZTERM_BIN="): path = line.split("=", 1)[1].strip() if path and Path(path).exists(): return path except Exception: - pass + continue return None @@ -43,11 +115,11 @@ def _load_cached_wezterm_bin() -> str | None: def _get_wezterm_bin() -> str | None: - """获取 WezTerm 路径(带缓存)""" + """Get WezTerm path (with cache)""" global _cached_wezterm_bin if _cached_wezterm_bin: return _cached_wezterm_bin - # 优先级: 环境变量 > 安装缓存 > PATH > 硬编码路径 + # Priority: env var > install cache > PATH > hardcoded paths override = os.environ.get("CODEX_WEZTERM_BIN") or os.environ.get("WEZTERM_BIN") if override and Path(override).exists(): _cached_wezterm_bin = override @@ -71,7 +143,7 @@ def _get_wezterm_bin() -> str | None: def _is_windows_wezterm() -> bool: - """检测 WezTerm 是否运行在 Windows 上""" + """Detect if WezTerm is running on Windows""" override = os.environ.get("CODEX_WEZTERM_BIN") or os.environ.get("WEZTERM_BIN") if override: if ".exe" in override.lower() or "/mnt/" in override: @@ -99,6 +171,8 @@ def _default_shell() -> tuple[str, str]: def get_shell_type() -> str: + if is_windows() and os.environ.get("CCB_BACKEND_ENV", "").lower() == "wsl": + return "bash" shell, _ = _default_shell() if shell in ("pwsh", "powershell"): return "powershell" @@ -130,12 +204,16 @@ def send_text(self, session: str, text: str) -> None: return buffer_name = f"tb-{os.getpid()}-{int(time.time() * 1000)}" - encoded = (sanitized + "\n").encode("utf-8") + encoded = sanitized.encode("utf-8") subprocess.run(["tmux", "load-buffer", "-b", buffer_name, "-"], input=encoded, check=True) - subprocess.run(["tmux", "paste-buffer", "-t", session, "-b", buffer_name, "-p"], check=True) - time.sleep(0.02) - subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True) - subprocess.run(["tmux", "delete-buffer", "-b", buffer_name], stderr=subprocess.DEVNULL) + try: + subprocess.run(["tmux", "paste-buffer", "-t", session, "-b", buffer_name, "-p"], check=True) + enter_delay = _env_float("CCB_TMUX_ENTER_DELAY", 0.0) + if enter_delay: + time.sleep(enter_delay) + subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True) + finally: + subprocess.run(["tmux", "delete-buffer", "-b", buffer_name], stderr=subprocess.DEVNULL) def is_alive(self, session: str) -> bool: result = subprocess.run(["tmux", "has-session", "-t", session], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -154,7 +232,7 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int class Iterm2Backend(TerminalBackend): - """iTerm2 后端,使用 it2 CLI (pip install it2)""" + """iTerm2 backend, using it2 CLI (pip install it2)""" _it2_bin: Optional[str] = None @classmethod @@ -172,15 +250,15 @@ def send_text(self, session_id: str, text: str) -> None: sanitized = text.replace("\r", "").strip() if not sanitized: return - # 类似 WezTerm 的方式:先发送文本,再发送回车 - # it2 session send 发送文本(不带换行) + # Similar to WezTerm: send text first, then send Enter + # it2 session send sends text (without newline) subprocess.run( [self._bin(), "session", "send", sanitized, "--session", session_id], check=True, ) - # 等待一点时间,让 TUI 处理输入 + # Wait a bit for TUI to process input time.sleep(0.01) - # 发送回车键(使用 \r) + # Send Enter key (using \r) subprocess.run( [self._bin(), "session", "send", "\r", "--session", session_id], check=True, @@ -190,7 +268,10 @@ def is_alive(self, session_id: str) -> bool: try: result = subprocess.run( [self._bin(), "session", "list", "--json"], - capture_output=True, text=True + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", ) if result.returncode != 0: return False @@ -209,29 +290,29 @@ def activate(self, session_id: str) -> None: subprocess.run([self._bin(), "session", "focus", session_id]) def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int = 50, parent_pane: Optional[str] = None) -> str: - # iTerm2 分屏:vertical 对应 right,horizontal 对应 bottom + # iTerm2 split: vertical corresponds to right, horizontal to bottom args = [self._bin(), "session", "split"] if direction == "right": args.append("--vertical") - # 如果有 parent_pane,指定目标 session + # If parent_pane specified, target that session if parent_pane: args.extend(["--session", parent_pane]) - result = subprocess.run(args, capture_output=True, text=True, check=True) - # it2 输出格式: "Created new pane: " + result = subprocess.run(args, capture_output=True, text=True, check=True, encoding="utf-8", errors="replace") + # it2 output format: "Created new pane: " output = result.stdout.strip() if ":" in output: new_session_id = output.split(":")[-1].strip() else: - # 尝试从 stderr 或其他地方获取 + # Try to get from stderr or elsewhere new_session_id = output - # 在新 pane 中执行启动命令 + # Execute startup command in new pane if new_session_id and cmd: - # 先 cd 到工作目录,再执行命令 + # First cd to work directory, then execute command full_cmd = f"cd {shlex.quote(cwd)} && {cmd}" - time.sleep(0.2) # 等待 pane 就绪 - # 使用 send + 回车的方式,与 send_text 保持一致 + time.sleep(0.2) # Wait for pane ready + # Use send + Enter, consistent with send_text subprocess.run( [self._bin(), "session", "send", full_cmd, "--session", new_session_id], check=True @@ -247,6 +328,7 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int class WeztermBackend(TerminalBackend): _wezterm_bin: Optional[str] = None + CCB_TITLE_MARKER = "CCB" @classmethod def _cli_base_args(cls) -> list[str]: @@ -268,39 +350,135 @@ def _bin(cls) -> str: cls._wezterm_bin = found or "wezterm" return cls._wezterm_bin + def _send_enter(self, pane_id: str) -> None: + """Send Enter key reliably using stdin (cross-platform)""" + # Windows needs longer delay + default_delay = 0.05 if os.name == "nt" else 0.01 + enter_delay = _env_float("CCB_WEZTERM_ENTER_DELAY", default_delay) + if enter_delay: + time.sleep(enter_delay) + + # Retry mechanism for reliability (Windows native occasionally drops Enter) + max_retries = 3 + for attempt in range(max_retries): + result = subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], + input=b"\r", + capture_output=True, + ) + if result.returncode == 0: + return + if attempt < max_retries - 1: + time.sleep(0.05) + def send_text(self, pane_id: str, text: str) -> None: sanitized = text.replace("\r", "").strip() if not sanitized: return - # tmux 可单独发 Enter 键;wezterm cli 没有 send-key,只能用 send-text 发送控制字符。 - # 经验上,很多交互式 CLI 在“粘贴/多行输入”里不会自动执行;这里将文本和 Enter 分两次发送更可靠。 + + has_newlines = "\n" in sanitized + + # Single-line: always avoid paste mode (prevents Codex showing "[Pasted Content ...]"). + # Use argv for short text; stdin for long text to avoid command-line length/escaping issues. + if not has_newlines: + if len(sanitized) <= 200: + subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste", sanitized], + check=True, + ) + else: + subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], + input=sanitized.encode("utf-8"), + check=True, + ) + self._send_enter(pane_id) + return + + # Slow path: multiline or long text -> use paste mode (bracketed paste) subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], + [*self._cli_base_args(), "send-text", "--pane-id", pane_id], input=sanitized.encode("utf-8"), check=True, ) - # 给 TUI 一点时间退出“粘贴/突发输入”路径,再发送 Enter 更像真实按键 - time.sleep(0.01) + + # Wait for TUI to process bracketed paste content + paste_delay = _env_float("CCB_WEZTERM_PASTE_DELAY", 0.1) + if paste_delay: + time.sleep(paste_delay) + + self._send_enter(pane_id) + + def _list_panes(self) -> list[dict]: try: - subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], - input=b"\r", - check=True, - ) - except subprocess.CalledProcessError: - subprocess.run( - [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], - input=b"\n", - check=True, + result = subprocess.run( + [*self._cli_base_args(), "list", "--format", "json"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", ) + if result.returncode != 0: + return [] + panes = json.loads(result.stdout) + return panes if isinstance(panes, list) else [] + except Exception: + return [] + + def _pane_id_by_title_marker(self, panes: list[dict], marker: str) -> Optional[str]: + if not marker: + return None + for pane in panes: + title = pane.get("title") or "" + if marker in title: + pane_id = pane.get("pane_id") + if pane_id is not None: + return str(pane_id) + return None + + def find_pane_by_title_marker(self, marker: str) -> Optional[str]: + panes = self._list_panes() + return self._pane_id_by_title_marker(panes, marker) def is_alive(self, pane_id: str) -> bool: + panes = self._list_panes() + if not panes: + return False + if any(str(p.get("pane_id")) == str(pane_id) for p in panes): + return True + return self._pane_id_by_title_marker(panes, pane_id) is not None + + def get_text(self, pane_id: str, lines: int = 20) -> Optional[str]: + """Get text content from pane (last N lines).""" try: - result = subprocess.run([*self._cli_base_args(), "list", "--format", "json"], capture_output=True, text=True) + result = subprocess.run( + [*self._cli_base_args(), "get-text", "--pane-id", pane_id], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=2.0, + ) if result.returncode != 0: - return False - panes = json.loads(result.stdout) - return any(str(p.get("pane_id")) == str(pane_id) for p in panes) + return None + text = result.stdout + if lines and text: + text_lines = text.splitlines() + return "\n".join(text_lines[-lines:]) + return text + except Exception: + return None + + def send_key(self, pane_id: str, key: str) -> bool: + """Send a special key (e.g., 'Escape', 'Enter') to pane.""" + try: + result = subprocess.run( + [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"], + input=key.encode("utf-8"), + capture_output=True, + timeout=2.0, + ) + return result.returncode == 0 except Exception: return False @@ -312,15 +490,20 @@ def activate(self, pane_id: str) -> None: def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int = 50, parent_pane: Optional[str] = None) -> str: args = [*self._cli_base_args(), "split-pane"] - if is_wsl() and _is_windows_wezterm(): + force_wsl = os.environ.get("CCB_BACKEND_ENV", "").lower() == "wsl" + wsl_unc_cwd = _extract_wsl_path_from_unc_like_path(cwd) + # If the caller is in a WSL UNC path (e.g. Git Bash `/wsl.localhost/...`), + # default to launching via wsl.exe so the new pane lands in the real WSL path. + if is_windows() and wsl_unc_cwd and not force_wsl: + force_wsl = True + use_wsl_launch = (is_wsl() and _is_windows_wezterm()) or (force_wsl and is_windows()) + if use_wsl_launch: in_wsl_pane = bool(os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP")) - wsl_cwd = cwd - wsl_localhost_match = re.match(r'^[/\\]{1,2}wsl\.localhost[/\\][^/\\]+(.+)$', cwd, re.IGNORECASE) - if wsl_localhost_match: - wsl_cwd = wsl_localhost_match.group(1).replace('\\', '/') - elif "\\" in cwd or (len(cwd) > 2 and cwd[1] == ":"): + wsl_cwd = wsl_unc_cwd or cwd + if wsl_unc_cwd is None and ("\\" in cwd or (len(cwd) > 2 and cwd[1] == ":")): try: - result = subprocess.run(["wslpath", "-a", cwd], capture_output=True, text=True, check=True) + wslpath_cmd = ["wslpath", "-a", cwd] if is_wsl() else ["wsl.exe", "wslpath", "-a", cwd] + result = subprocess.run(wslpath_cmd, capture_output=True, text=True, check=True, encoding="utf-8", errors="replace") wsl_cwd = result.stdout.strip() except Exception: pass @@ -331,7 +514,8 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int args.extend(["--percent", str(percent)]) if parent_pane: args.extend(["--pane-id", parent_pane]) - startup_script = f"cd {shlex.quote(wsl_cwd)} && exec {cmd}" + # Do not `exec` here: `cmd` may be a compound shell snippet (e.g. keep-open wrappers). + startup_script = f"cd {shlex.quote(wsl_cwd)} && {cmd}" if in_wsl_pane: args.extend(["--", "bash", "-l", "-i", "-c", startup_script]) else: @@ -347,28 +531,42 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int args.extend(["--pane-id", parent_pane]) shell, flag = _default_shell() args.extend(["--", shell, flag, cmd]) - result = subprocess.run(args, capture_output=True, text=True, check=True) - return result.stdout.strip() + try: + run_cwd = None + if is_wsl() and _is_windows_wezterm(): + run_cwd = _choose_wezterm_cli_cwd() + result = subprocess.run( + args, + capture_output=True, + text=True, + check=True, + encoding="utf-8", + errors="replace", + cwd=run_cwd, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"WezTerm split-pane failed:\nCommand: {' '.join(args)}\nStderr: {e.stderr}") from e _backend_cache: Optional[TerminalBackend] = None def detect_terminal() -> Optional[str]: - # 优先检测当前环境变量(已在某终端中运行) + # Priority: check current env vars (already running in a terminal) if os.environ.get("WEZTERM_PANE"): return "wezterm" if os.environ.get("ITERM_SESSION_ID"): return "iterm2" if os.environ.get("TMUX"): return "tmux" - # 检查配置的二进制覆盖或缓存路径 + # Check configured binary override or cached path if _get_wezterm_bin(): return "wezterm" override = os.environ.get("CODEX_IT2_BIN") or os.environ.get("IT2_BIN") if override and Path(override).expanduser().exists(): return "iterm2" - # 检查可用的终端工具 + # Check available terminal tools if shutil.which("it2"): return "iterm2" if shutil.which("tmux") or shutil.which("tmux.exe"): diff --git a/lib/worker_pool.py b/lib/worker_pool.py new file mode 100644 index 0000000..43eb32e --- /dev/null +++ b/lib/worker_pool.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import queue +import threading +from typing import Callable, Generic, Optional, Protocol, TypeVar + + +ResultT = TypeVar("ResultT") + + +class QueuedTaskLike(Protocol[ResultT]): + req_id: str + done_event: threading.Event + result: Optional[ResultT] + + +TaskT = TypeVar("TaskT", bound=QueuedTaskLike) + + +class BaseSessionWorker(threading.Thread, Generic[TaskT, ResultT]): + def __init__(self, session_key: str): + super().__init__(daemon=True) + self.session_key = session_key + self._q: "queue.Queue[TaskT]" = queue.Queue() + self._stop_event = threading.Event() + + def enqueue(self, task: TaskT) -> None: + self._q.put(task) + + def stop(self) -> None: + self._stop_event.set() + + def run(self) -> None: + while not self._stop_event.is_set(): + try: + task = self._q.get(timeout=0.2) + except queue.Empty: + continue + try: + task.result = self._handle_task(task) + except Exception as exc: + task.result = self._handle_exception(exc, task) + finally: + task.done_event.set() + + def _handle_task(self, task: TaskT) -> ResultT: + raise NotImplementedError + + def _handle_exception(self, exc: Exception, task: TaskT) -> ResultT: + raise NotImplementedError + + +WorkerT = TypeVar("WorkerT", bound=threading.Thread) + + +class PerSessionWorkerPool(Generic[WorkerT]): + def __init__(self): + self._lock = threading.Lock() + self._workers: dict[str, WorkerT] = {} + + def get_or_create(self, session_key: str, factory: Callable[[str], WorkerT]) -> WorkerT: + created = False + with self._lock: + worker = self._workers.get(session_key) + if worker is None: + worker = factory(session_key) + self._workers[session_key] = worker + created = True + if created: + worker.start() + return worker diff --git a/skills/cask/SKILL.md b/skills/cask/SKILL.md new file mode 100644 index 0000000..e567a06 --- /dev/null +++ b/skills/cask/SKILL.md @@ -0,0 +1,25 @@ +--- +name: cask +description: Async via cask, end turn immediately; use only when user explicitly delegates to Codex (ask/@codex/let codex/review); NOT for questions about Codex itself. +--- + +# Ask Codex (Async) + +Send the user’s request to Codex asynchronously. + +## Execution (MANDATORY) + +``` +Bash(cask <<'EOF' +$ARGUMENTS +EOF +, run_in_background=true) +``` + +## CRITICAL Rules + +- Always use `run_in_background=true`. +- After running `cask`, say “Codex processing...” and immediately end your turn. +- Do not wait for results or check status in the same turn. + +Details: `~/.claude/skills/docs/async-ask-pattern.md` diff --git a/skills/docs/async-ask-pattern.md b/skills/docs/async-ask-pattern.md new file mode 100644 index 0000000..74ac342 --- /dev/null +++ b/skills/docs/async-ask-pattern.md @@ -0,0 +1,59 @@ +# Async Ask Pattern (cask/gask/oask) + +Use this pattern to delegate work to a partner AI (Codex/Gemini/OpenCode) asynchronously. + +## When To Use + +Use ONLY when the user explicitly delegates to one of: +- Codex: `@codex`, `ask codex`, `let codex`, “让 codex …”, “请 codex …” +- Gemini: `@gemini`, `ask gemini`, `let gemini`, “让 gemini …”, “请 gemini …” +- OpenCode: `@opencode`, `ask opencode`, `let opencode`, “让 opencode …”, “请 opencode …” + +DO NOT use when the user asks questions *about* the tool itself. + +## Command Mapping + +- Codex → `cask` → status: `ccb status codex` +- Gemini → `gask` → status: `ccb status gemini` +- OpenCode → `oask` → status: `ccb status opencode` + +## Execution (MANDATORY) + +Always run in background and pass the user request via a single-quoted HEREDOC (prevents shell backtick/`$()` expansion): + +```bash +Bash( <<'EOF' + +EOF +, run_in_background=true) +``` + +## Workflow (IMPORTANT) + +1. Submit task to background (partner AI processes elsewhere). +2. Immediately end the current turn. Do not wait. +3. The system will recall you when the background command returns. + +## After Execution (MANDATORY) + +If Bash succeeds: +- Tell the user: “ processing...” +- Immediately end your turn. +- Do not check status or do additional work in the same turn. + +If Bash fails: +- Report the error output. +- Suggest checking backend status with the mapping above. + +## Wrong vs Right + +- Wrong: `Bash(cask "…")` (may break on backticks; may block) +- Right: `Bash(cask <<'EOF' … EOF, run_in_background=true)` then end turn + +## Parameters + +Supported by `cask/gask/oask`: +- `--timeout SECONDS` (default 3600) +- `--output FILE` (write reply to FILE) +- `-q/--quiet` (reduce stderr noise; timeout still returns non-zero) + diff --git a/skills/gask/SKILL.md b/skills/gask/SKILL.md new file mode 100644 index 0000000..b3c23a6 --- /dev/null +++ b/skills/gask/SKILL.md @@ -0,0 +1,25 @@ +--- +name: gask +description: Async via gask, end turn immediately; use only when user explicitly delegates to Gemini (ask/@gemini/let gemini/review); NOT for questions about Gemini itself. +--- + +# Ask Gemini (Async) + +Send the user’s request to Gemini asynchronously. + +## Execution (MANDATORY) + +``` +Bash(gask <<'EOF' +$ARGUMENTS +EOF +, run_in_background=true) +``` + +## CRITICAL Rules + +- Always use `run_in_background=true`. +- After running `gask`, say “Gemini processing...” and immediately end your turn. +- Do not wait for results or check status in the same turn. + +Details: `~/.claude/skills/docs/async-ask-pattern.md` diff --git a/skills/oask/SKILL.md b/skills/oask/SKILL.md new file mode 100644 index 0000000..df889e0 --- /dev/null +++ b/skills/oask/SKILL.md @@ -0,0 +1,25 @@ +--- +name: oask +description: Async via oask, end turn immediately; use only when user explicitly delegates to OpenCode (ask/@opencode/let opencode/review); NOT for questions about OpenCode itself. +--- + +# Ask OpenCode (Async) + +Send the user’s request to OpenCode asynchronously. + +## Execution (MANDATORY) + +``` +Bash(oask <<'EOF' +$ARGUMENTS +EOF +, run_in_background=true) +``` + +## CRITICAL Rules + +- Always use `run_in_background=true`. +- After running `oask`, say “OpenCode processing...” and immediately end your turn. +- Do not wait for results or check status in the same turn. + +Details: `~/.claude/skills/docs/async-ask-pattern.md` diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..318f902 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +def pytest_configure() -> None: + repo_root = Path(__file__).resolve().parents[1] + lib_dir = repo_root / "lib" + sys.path.insert(0, str(lib_dir)) + diff --git a/test/test_ccb_protocol.py b/test/test_ccb_protocol.py new file mode 100644 index 0000000..c587de3 --- /dev/null +++ b/test/test_ccb_protocol.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import re + +from ccb_protocol import DONE_PREFIX, REQ_ID_PREFIX, is_done_text, make_req_id, strip_done_text, wrap_codex_prompt + + +def test_make_req_id_format_and_uniqueness() -> None: + ids = [make_req_id() for _ in range(2000)] + assert len(set(ids)) == len(ids) + for rid in ids: + assert isinstance(rid, str) + assert len(rid) == 32 + assert re.fullmatch(r"[0-9a-f]{32}", rid) is not None + + +def test_wrap_codex_prompt_structure() -> None: + req_id = make_req_id() + message = "hello\nworld" + prompt = wrap_codex_prompt(message, req_id) + + assert f"{REQ_ID_PREFIX} {req_id}" in prompt + assert "IMPORTANT:" in prompt + assert "- Reply normally." in prompt + assert f"{DONE_PREFIX} {req_id}" in prompt + assert prompt.endswith(f"{DONE_PREFIX} {req_id}\n") + + +def test_is_done_text_recognizes_last_nonempty_line() -> None: + req_id = make_req_id() + ok = f"hi\n{DONE_PREFIX} {req_id}\n" + assert is_done_text(ok, req_id) is True + + ok_with_trailing_blanks = f"hi\n{DONE_PREFIX} {req_id}\n\n\n" + assert is_done_text(ok_with_trailing_blanks, req_id) is True + + not_last = f"{DONE_PREFIX} {req_id}\nhi\n" + assert is_done_text(not_last, req_id) is False + + other_id = make_req_id() + wrong_id = f"hi\n{DONE_PREFIX} {other_id}\n" + assert is_done_text(wrong_id, req_id) is False + + +def test_strip_done_text_removes_done_line() -> None: + req_id = make_req_id() + text = f"line1\nline2\n{DONE_PREFIX} {req_id}\n\n" + assert strip_done_text(text, req_id) == "line1\nline2" + diff --git a/test/test_env_utils.py b/test/test_env_utils.py new file mode 100644 index 0000000..d0f92e2 --- /dev/null +++ b/test/test_env_utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os + +from env_utils import env_bool + + +def test_env_bool_truthy_and_falsy(monkeypatch) -> None: + monkeypatch.delenv("X", raising=False) + assert env_bool("X", default=True) is True + assert env_bool("X", default=False) is False + + for v in ("1", "true", "yes", "on", " TRUE ", "Yes"): + monkeypatch.setenv("X", v) + assert env_bool("X", default=False) is True + + for v in ("0", "false", "no", "off", " 0 ", "False"): + monkeypatch.setenv("X", v) + assert env_bool("X", default=True) is False + + monkeypatch.setenv("X", "maybe") + assert env_bool("X", default=True) is True + assert env_bool("X", default=False) is False + diff --git a/test/test_integration.py b/test/test_integration.py new file mode 100644 index 0000000..2e77381 --- /dev/null +++ b/test/test_integration.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import json +import os +import sys +import time +import types +from pathlib import Path +from threading import Thread +from typing import Any, Callable, Generator + +import pytest + +import askd_rpc +from askd_client import try_daemon_request +from askd_server import AskDaemonServer +from providers import ProviderClientSpec, ProviderDaemonSpec + + +def _wait_for_file(path: Path, timeout_s: float = 3.0) -> None: + deadline = time.time() + max(0.1, float(timeout_s)) + while time.time() < deadline: + if path.exists() and path.stat().st_size > 0: + return + time.sleep(0.05) + raise AssertionError(f"Timed out waiting for file: {path}") + + +def _make_spec() -> ProviderDaemonSpec: + unique = f"itest-{os.getpid()}-{int(time.time() * 1000)}" + return ProviderDaemonSpec( + daemon_key=unique, + protocol_prefix="itest", + state_file_name=f"{unique}.json", + log_file_name=f"{unique}.log", + idle_timeout_env="CCB_ITEST_IDLE_TIMEOUT_S", + lock_name=unique, + ) + + +@pytest.fixture() +def daemon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[tuple[ProviderDaemonSpec, Path, Thread], None, None]: + # Isolate HOME (ProviderLock uses Path.home()) and run dir (askd_runtime). + fake_home = tmp_path / "home" + fake_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("CCB_RUN_DIR", str(tmp_path / "run")) + monkeypatch.setenv("CCB_ITEST_IDLE_TIMEOUT_S", "0") # disable idle shutdown in tests + + spec = _make_spec() + state_file = tmp_path / "state" / "itest.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + + def handler(msg: dict) -> dict: + return { + "type": f"{spec.protocol_prefix}.response", + "v": 1, + "id": msg.get("id"), + "exit_code": 0, + "reply": f"echo:{msg.get('message') or ''}", + } + + server = AskDaemonServer( + spec=spec, + host="127.0.0.1", + port=0, + token="test-token", + state_file=state_file, + request_handler=handler, + ) + + thread = Thread(target=server.serve_forever, name="itest-daemon", daemon=True) + thread.start() + + _wait_for_file(state_file, timeout_s=3.0) + yield spec, state_file, thread + + # Best-effort shutdown if still alive. + try: + askd_rpc.shutdown_daemon(spec.protocol_prefix, timeout_s=0.5, state_file=state_file) + except Exception: + pass + thread.join(timeout=3.0) + + +def test_daemon_server_writes_state_file(daemon: tuple[ProviderDaemonSpec, Path, Thread]) -> None: + spec, state_file, _thread = daemon + st = askd_rpc.read_state(state_file) + assert isinstance(st, dict) + for k in ("pid", "host", "port", "token"): + assert k in st + assert st["token"] + assert int(st["pid"]) > 0 + assert int(st["port"]) > 0 + # host may be "127.0.0.1" or "localhost" depending on socketserver + assert isinstance(st["host"], str) and st["host"] + # connect_host should exist (AskDaemonServer writes it) + assert isinstance(st.get("connect_host"), str) + assert st.get("connect_host") + + +def test_daemon_ping_pong(daemon: tuple[ProviderDaemonSpec, Path, Thread]) -> None: + spec, state_file, _thread = daemon + assert askd_rpc.ping_daemon(spec.protocol_prefix, timeout_s=0.5, state_file=state_file) is True + + +def test_daemon_shutdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Use a dedicated daemon instance for this test so we can shut it down. + fake_home = tmp_path / "home" + fake_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("CCB_RUN_DIR", str(tmp_path / "run")) + monkeypatch.setenv("CCB_ITEST_IDLE_TIMEOUT_S", "0") + + spec = _make_spec() + state_file = tmp_path / "state" / "itest.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + + def handler(_msg: dict) -> dict: + return {"type": f"{spec.protocol_prefix}.response", "v": 1, "id": "x", "exit_code": 0, "reply": "OK"} + + server = AskDaemonServer( + spec=spec, + host="127.0.0.1", + port=0, + token="test-token", + state_file=state_file, + request_handler=handler, + ) + thread = Thread(target=server.serve_forever, name="itest-daemon-shutdown", daemon=True) + thread.start() + _wait_for_file(state_file, timeout_s=3.0) + + assert askd_rpc.ping_daemon(spec.protocol_prefix, timeout_s=0.5, state_file=state_file) is True + assert askd_rpc.shutdown_daemon(spec.protocol_prefix, timeout_s=0.5, state_file=state_file) is True + thread.join(timeout=3.0) + assert askd_rpc.ping_daemon(spec.protocol_prefix, timeout_s=0.2, state_file=state_file) is False + + +def test_client_try_daemon_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fake_home = tmp_path / "home" + fake_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("CCB_RUN_DIR", str(tmp_path / "run")) + monkeypatch.setenv("CCB_ITEST_IDLE_TIMEOUT_S", "0") + + spec = _make_spec() + state_file = tmp_path / "state" / "itest.json" + state_file.parent.mkdir(parents=True, exist_ok=True) + + def handler(msg: dict) -> dict: + return { + "type": f"{spec.protocol_prefix}.response", + "v": 1, + "id": msg.get("id"), + "exit_code": 0, + "reply": f"echo:{msg.get('message') or ''}", + } + + server = AskDaemonServer( + spec=spec, + host="127.0.0.1", + port=0, + token="test-token", + state_file=state_file, + request_handler=handler, + ) + thread = Thread(target=server.serve_forever, name="itest-daemon-client", daemon=True) + thread.start() + _wait_for_file(state_file, timeout_s=3.0) + + # Create a fake daemon module that exposes read_state(state_file=...), + # so askd_client.try_daemon_request can import it. + module_name = "itest_daemon_module" + mod = types.ModuleType(module_name) + + def _read_state(*, state_file: Path | None = None) -> dict | None: + if state_file is None: + return None + return askd_rpc.read_state(state_file) + + mod.read_state = _read_state # type: ignore[attr-defined] + sys.modules[module_name] = mod + + client_spec = ProviderClientSpec( + protocol_prefix=spec.protocol_prefix, + enabled_env="CCB_ITEST_ENABLED", + autostart_env_primary="CCB_ITEST_AUTOSTART", + autostart_env_legacy="CCB_AUTO_ITEST", + state_file_env="CCB_ITEST_STATE_FILE", + session_filename=".itest-session", + daemon_bin_name="itestd", + daemon_module=module_name, + ) + + monkeypatch.setenv(client_spec.enabled_env, "1") + # try_daemon_request requires a session file to exist in the work dir (or a parent). + work_dir = tmp_path / "work" + work_dir.mkdir(parents=True, exist_ok=True) + (work_dir / client_spec.session_filename).write_text("{}", encoding="utf-8") + + reply, exit_code = try_daemon_request( + client_spec, + work_dir, + message="hello", + timeout=1.0, + quiet=True, + state_file=state_file, + ) or (None, None) + assert reply == "echo:hello" + assert exit_code == 0 + + assert askd_rpc.shutdown_daemon(spec.protocol_prefix, timeout_s=0.5, state_file=state_file) is True + thread.join(timeout=3.0) + diff --git a/test/test_session_utils.py b/test/test_session_utils.py new file mode 100644 index 0000000..9e42cee --- /dev/null +++ b/test/test_session_utils.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +from session_utils import find_project_session_file, safe_write_session + + +def test_find_project_session_file_walks_upwards(tmp_path: Path) -> None: + root = tmp_path / "repo" + leaf = root / "a" / "b" / "c" + leaf.mkdir(parents=True) + + session = root / ".codex-session" + session.write_text("{}", encoding="utf-8") + + found = find_project_session_file(leaf, ".codex-session") + assert found == session + + +def test_safe_write_session_atomic_write(tmp_path: Path) -> None: + target = tmp_path / "state.json" + ok, err = safe_write_session(target, '{"hello":"world"}\n') + assert ok is True + assert err is None + assert target.read_text(encoding="utf-8") == '{"hello":"world"}\n' + assert not target.with_suffix(".tmp").exists() + + ok2, err2 = safe_write_session(target, '{"hello":"again"}\n') + assert ok2 is True + assert err2 is None + assert target.read_text(encoding="utf-8") == '{"hello":"again"}\n' + assert not target.with_suffix(".tmp").exists() + diff --git a/test/test_worker_pool.py b/test/test_worker_pool.py new file mode 100644 index 0000000..02ba7c4 --- /dev/null +++ b/test/test_worker_pool.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Optional + +from worker_pool import BaseSessionWorker, PerSessionWorkerPool + + +class _NoopThread(threading.Thread): + def __init__(self, session_key: str, started: list[str]): + super().__init__(daemon=True) + self.session_key = session_key + self._started = started + + def start(self) -> None: # type: ignore[override] + self._started.append(self.session_key) + + +def test_per_session_worker_pool_reuses_same_key() -> None: + started: list[str] = [] + pool: PerSessionWorkerPool[_NoopThread] = PerSessionWorkerPool() + w1 = pool.get_or_create("k1", lambda k: _NoopThread(k, started)) + w2 = pool.get_or_create("k1", lambda k: _NoopThread(k, started)) + w3 = pool.get_or_create("k2", lambda k: _NoopThread(k, started)) + + assert w1 is w2 + assert w1 is not w3 + assert started.count("k1") == 1 + assert started.count("k2") == 1 + + +@dataclass +class _Task: + req_id: str + done_event: threading.Event + result: Optional[str] = None + + +class _EchoWorker(BaseSessionWorker[_Task, str]): + def _handle_task(self, task: _Task) -> str: + return f"ok:{task.req_id}" + + def _handle_exception(self, exc: Exception, task: _Task) -> str: + return f"err:{task.req_id}:{exc}" + + +class _FailWorker(_EchoWorker): + def _handle_task(self, task: _Task) -> str: + raise RuntimeError("boom") + + +def test_base_session_worker_processes_task_and_sets_event() -> None: + worker = _EchoWorker("s1") + worker.start() + try: + task = _Task(req_id="r1", done_event=threading.Event()) + worker.enqueue(task) + assert task.done_event.wait(timeout=2.0) is True + assert task.result == "ok:r1" + finally: + worker.stop() + worker.join(timeout=2.0) + + +def test_base_session_worker_exception_path() -> None: + worker = _FailWorker("s1") + worker.start() + try: + task = _Task(req_id="r2", done_event=threading.Event()) + worker.enqueue(task) + assert task.done_event.wait(timeout=2.0) is True + assert task.result is not None + assert task.result.startswith("err:r2:") + finally: + worker.stop() + worker.join(timeout=2.0) +