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.**
-[]()
+
+
+
+
+
+
+
+
+
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
+[](https://github.com/bfly123/claude_code_bridge/actions/workflows/test.yml)
[]()
-[English](#english) | [中文](#中文)
-
-

+**English** | [中文](README_zh.md)
-
- Full demo video (GitHub Release)
-
+
---
-## 🎉 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
+
+
+
-| 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` | 测试连通性 |
+
-**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**
-

+📧 Email: bfly123@126.com
+💬 WeChat: seemseam-com
+
+
+
+---
+
+
+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 配上"不会遗忘"的搭档**
+
+
+
+
+
+
+
+
+
+
+[]()
+[]()
+
+[English](README.md) | **中文**
+
+

+
+
+
+---
+
+**简介:** 多模型协作能够有效避免模型偏见、认知漏洞和上下文限制,然而 MCP、Skills 等直接调用 API 方式存在诸多局限性。本项目打造了一套新的方案。
+
+## ⚡ 核心优势
+
+| 特性 | 价值 |
+| :--- | :--- |
+| **🖥️ 可见可控** | 多模型分屏 CLI 挂载,所见即所得,完全掌控。 |
+| **🧠 持久上下文** | 每个 AI 独立记忆,关闭后可随时恢复(`-r` 参数)。 |
+| **📉 节省 Token** | 仅发送轻量级指令,而非整个代码库历史 (~20k tokens)。 |
+| **🪟 原生终端体验** | 直接集成于 **WezTerm** (推荐) 或 tmux,无需配置复杂的服务器。 |
+
+---
+
+🚀 v3.0 新版本特性
+
+> **跨 AI 协作的终极桥梁**
+
+v3.0 带来了革命性的 **智能守护进程 (Smart Daemons)** 架构,实现了并行执行、跨 Agent 协调和企业级稳定性。
+
+
+
+
+
+
+
+
+
+✨ 核心特性
+
+- **🔄 真·并行**: 同时提交多个任务给 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** 等编辑器,实现无缝的代码编辑与多模型审查工作流。在你喜欢的编辑器中编写代码,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)
+