diff --git a/.gitignore b/.gitignore
index 0fc67c8..1bc656e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ __pycache__/
.claude-session
.claude/
*.mp4
+tmp/
diff --git a/CLAUDE.md b/CLAUDE.md
index 6f8b745..71c1623 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1 @@
-- 该文件夹是 claude_code_bridge (ccb) 开发文件夹,要注意兼容性,同时修改代码注意同时修改install,安装使用install安装,完成后要git增加版本并推送
\ No newline at end of file
+- This is the claude_code_bridge (ccb) development folder. Pay attention to compatibility. When modifying code, also update install scripts. Use install.sh/install.ps1 to install. After completion, git commit and push.
\ 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..139ba4d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
**Windows | macOS | Linux — One Tool, All Platforms**
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
[]()
@@ -184,6 +184,8 @@ ccb update # Update to latest version
- Python 3.10+
- tmux or WezTerm (at least one; WezTerm recommended)
+> **⚠️ Windows Users:** Always install WezTerm using the **native Windows .exe installer** from [wezfurlong.org/wezterm](https://wezfurlong.org/wezterm/), even if you use WSL. Do NOT install WezTerm inside WSL. After installation, configure WezTerm to connect to WSL via `wsl.exe` as the default shell. This ensures proper split-pane functionality.
+
## Uninstall
```bash
@@ -370,8 +372,9 @@ ccb update # 更新到最新版本
## 依赖
- Python 3.10+
-- tmux 或 WezTerm(至少安装一个),强烈推荐wezterm
+- tmux 或 WezTerm(至少安装一个),强烈推荐 WezTerm
+> **⚠️ Windows 用户注意:** 必须使用 **Windows 原生 .exe 安装包** 安装 WezTerm([下载地址](https://wezfurlong.org/wezterm/)),即使你使用 WSL 也是如此。**不要在 WSL 内部安装 WezTerm**。安装完成后,可在 WezTerm 设置中将默认 shell 配置为 `wsl.exe`,即可无缝接入 WSL 环境,同时保证分屏功能正常工作。
## 卸载
diff --git a/bin/cask b/bin/cask
index 27a053c..11c2da2 100755
--- a/bin/cask
+++ b/bin/cask
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-cask - 将消息转发到 Codex 会话
+cask - Forward message to Codex session
"""
from __future__ import annotations
import json
@@ -12,12 +12,14 @@ 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 terminal import get_backend_for_session, get_pane_id_from_session
def _usage() -> None:
- print("用法: cask <消息>", file=sys.stderr)
+ print("Usage: cask ", file=sys.stderr)
def _load_session() -> Optional[dict]:
@@ -46,10 +48,10 @@ def _load_session() -> Optional[dict]:
def _resolve_session() -> Tuple[dict, str]:
data = _load_session()
if not data:
- raise RuntimeError("❌ 未找到 Codex 会话,请先运行 ccb up codex")
+ raise RuntimeError("❌ Codex session not found, please run ccb up codex first")
pane_id = get_pane_id_from_session(data)
if not pane_id:
- raise RuntimeError("❌ 会话配置无效")
+ raise RuntimeError("❌ Session config invalid")
return data, pane_id
@@ -67,12 +69,12 @@ def main(argv: list[str]) -> int:
data, pane_id = _resolve_session()
backend = get_backend_for_session(data)
if not backend:
- raise RuntimeError("❌ 无法初始化终端后端")
+ raise RuntimeError("❌ Cannot initialize terminal backend")
if not backend.is_alive(pane_id):
terminal = data.get("terminal", "tmux")
- raise RuntimeError(f"❌ {terminal} 会话不存在: {pane_id}\n提示: 请确认 ccb 正在运行")
+ raise RuntimeError(f"❌ {terminal} session not found: {pane_id}\nHint: Please confirm ccb is running")
backend.send_text(pane_id, raw_command)
- print(f"✅ 已发送到 Codex ({pane_id})")
+ print(f"✅ Sent to Codex ({pane_id})")
return 0
except Exception as exc:
print(exc, file=sys.stderr)
diff --git a/bin/cask-w b/bin/cask-w
index 822cc7b..91de217 100755
--- a/bin/cask-w
+++ b/bin/cask-w
@@ -1,35 +1,94 @@
#!/usr/bin/env python3
"""
-cask-w - 同步发送消息到 Codex 并等待回复
+cask-w - Send message to Codex and wait for reply (pure sync mode)
+Designed to be run with Claude Code's run_in_background=true
"""
from __future__ import annotations
+import os
import sys
from pathlib import Path
+import json
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:
if len(argv) <= 1:
- print("用法: cask-w <消息>", file=sys.stderr)
+ print("Usage: cask-w ", file=sys.stderr)
return 1
message = " ".join(argv[1:]).strip()
if not message:
- print("❌ 消息内容不能为空", file=sys.stderr)
+ print("❌ Message cannot be empty", file=sys.stderr)
return 1
+ from codex_comm import CodexCommunicator
+ from i18n import t
+
+ def save_pending_state(state: dict) -> None:
+ session_file = Path.cwd() / ".codex-session"
+ if not session_file.exists():
+ return
+ try:
+ with session_file.open("r", encoding="utf-8-sig") as handle:
+ data = json.load(handle)
+ data["pending_state"] = {
+ "log_path": str(state.get("log_path")) if state.get("log_path") else None,
+ "offset": int(state.get("offset", 0) or 0),
+ }
+ tmp_file = session_file.with_suffix(".tmp")
+ with tmp_file.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, ensure_ascii=False, indent=2)
+ os.replace(tmp_file, session_file)
+ except Exception:
+ return
+
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"❌ Session error: {status}", file=sys.stderr)
+ return 1
+
+ # Send message
+ print(f"🔔 {t('sending_to', provider='Codex')}", flush=True)
+ marker, state = comm._send_message(message)
+ comm._remember_codex_session(state.get("log_path") or comm.log_reader.current_log_path())
+
+ # Pure sync wait (default 1 hour, configurable via CCB_SYNC_TIMEOUT)
+ sync_timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0"))
+ message_reply, _ = comm.log_reader.wait_for_message(state, sync_timeout)
+
+ # Save to cache
+ cache_dir = Path.home() / ".cache" / "ccb"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ reply_file = cache_dir / "codex_last_reply.txt"
+
+ if message_reply:
+ print(f"🤖 {t('reply_from', provider='Codex')}")
+ print(message_reply)
+ reply_file.write_text(message_reply, encoding="utf-8")
+ else:
+ print(f"⏰ Timeout after {int(sync_timeout)}s")
+ save_pending_state(state)
+ return 0
+ except KeyboardInterrupt:
+ # Best-effort: preserve pending state so /cpend can fetch later.
+ try:
+ save_pending_state(locals().get("state", {}) if isinstance(locals().get("state"), dict) else {})
+ except Exception:
+ pass
+ print("❌ Interrupted", file=sys.stderr)
+ return 130
except Exception as exc:
- print(exc, file=sys.stderr)
+ print(f"❌ {exc}", file=sys.stderr)
return 1
diff --git a/bin/cpend b/bin/cpend
index b971983..8747d57 100755
--- a/bin/cpend
+++ b/bin/cpend
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-cpend - 查看 Codex 最新回复
+cpend - View latest Codex reply
"""
import json
@@ -10,67 +10,103 @@ 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
+from session_utils import safe_write_session
try:
from codex_comm import CodexCommunicator
except ImportError as exc:
- print(f"导入失败: {exc}")
+ print(f"Import failed: {exc}")
sys.exit(1)
+def _load_cached_reply() -> str | None:
+ """Load cached reply from background cask-w process"""
+ cache_file = Path.home() / ".cache" / "ccb" / "codex_last_reply.txt"
+ if not cache_file.exists():
+ return None
+ try:
+ content = cache_file.read_text(encoding="utf-8").strip()
+ if content:
+ cache_file.unlink() # Clear after reading
+ return content
+ except Exception:
+ pass
+ return None
+
+
def _load_pending_state() -> dict:
- """从 .codex-session 加载 cask-w 超时时保存的状态"""
+ """Load pending state saved by cask-w timeout from .codex-session"""
session_file = Path.cwd() / ".codex-session"
if not session_file.exists():
return {}
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", {})
+ pending = data.get("pending_state", {})
+ return pending if isinstance(pending, dict) else {}
except Exception:
return {}
def _clear_pending_state() -> None:
- """清除 pending_state"""
+ """Clear pending_state"""
session_file = Path.cwd() / ".codex-session"
if not session_file.exists():
return
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)
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)
+ safe_write_session(session_file, json.dumps(data, ensure_ascii=False, indent=2))
except Exception:
pass
def main() -> int:
try:
+ # First check cached reply from background cask-w
+ cached = _load_cached_reply()
+ if cached:
+ print(f"🤖 Codex reply:")
+ print(cached)
+ return 0
+
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
+ try:
+ log_path = Path(str(pending.get("log_path"))).expanduser()
+ except Exception:
+ log_path = None
+
+ # Avoid falling back to "global latest" if the pending log path is missing,
+ # otherwise cpend may display an unrelated reply from another project.
+ if log_path and log_path.exists():
+ try:
+ offset = int(pending.get("offset", 0) or 0)
+ except Exception:
+ offset = 0
+ state = {"log_path": str(log_path), "offset": offset}
+ 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 回复')
+ print(t('no_reply_available', provider='Codex'))
return 0
except Exception as exc:
- print(f"❌ 执行失败: {exc}")
+ print(f"❌ {t('execution_failed', error=exc)}")
return 1
diff --git a/bin/cping b/bin/cping
index 730ca0e..79bad6d 100755
--- a/bin/cping
+++ b/bin/cping
@@ -1,17 +1,18 @@
#!/usr/bin/env python3
"""
-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"❌ 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"❌ 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..dba7dc8 100755
--- a/bin/gask
+++ b/bin/gask
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-gask - 异步发送消息到 Gemini
+gask - Async send message to Gemini
"""
from __future__ import annotations
@@ -11,18 +11,20 @@ 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 gemini_comm import GeminiCommunicator
def main(argv: list[str]) -> int:
if len(argv) <= 1:
- print("用法: gask <消息>", file=sys.stderr)
+ print("Usage: gask ", file=sys.stderr)
return 1
message = " ".join(argv[1:]).strip()
if not message:
- print("❌ 消息内容不能为空", file=sys.stderr)
+ print("❌ Message cannot be empty", file=sys.stderr)
return 1
try:
diff --git a/bin/gask-w b/bin/gask-w
index 518f1be..96267eb 100755
--- a/bin/gask-w
+++ b/bin/gask-w
@@ -1,39 +1,113 @@
#!/usr/bin/env python3
"""
-gask-w - 同步发送消息到 Gemini 并等待回复
+gask-w - Send message to Gemini and wait for reply (pure sync mode)
+Designed to be run with Claude Code's run_in_background=true
"""
-
from __future__ import annotations
-
+import os
import sys
from pathlib import Path
+import json
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:
if len(argv) <= 1:
- print("用法: gask-w <消息>", file=sys.stderr)
+ print("Usage: gask-w ", file=sys.stderr)
return 1
message = " ".join(argv[1:]).strip()
if not message:
- print("❌ 消息内容不能为空", file=sys.stderr)
+ print("❌ Message cannot be empty", file=sys.stderr)
return 1
+ from gemini_comm import GeminiCommunicator
+ from i18n import t
+
+ def save_pending_state(state: dict) -> None:
+ session_file = Path.cwd() / ".gemini-session"
+ if not session_file.exists():
+ return
+ try:
+ with session_file.open("r", encoding="utf-8-sig") as handle:
+ data = json.load(handle)
+ data["pending_state"] = {
+ "session_path": str(state.get("session_path")) if state.get("session_path") else None,
+ "msg_count": int(state.get("msg_count", 0) or 0),
+ "mtime": float(state.get("mtime", 0.0) or 0.0),
+ "mtime_ns": int(state.get("mtime_ns", 0) or 0),
+ "size": int(state.get("size", 0) or 0),
+ "last_gemini_id": state.get("last_gemini_id"),
+ "last_gemini_hash": state.get("last_gemini_hash"),
+ }
+ tmp_file = session_file.with_suffix(".tmp")
+ with tmp_file.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, ensure_ascii=False, indent=2)
+ os.replace(tmp_file, session_file)
+ except Exception:
+ return
+
+ # Save to cache
+ cache_dir = Path.home() / ".cache" / "ccb"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ reply_file = cache_dir / "gemini_last_reply.txt"
+ # Clear stale cache
+ if reply_file.exists():
+ try:
+ reply_file.unlink()
+ except Exception:
+ pass
+
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"❌ Session error: {status}", file=sys.stderr)
+ return 1
+
+ # Send message
+ print(f"🔔 {t('sending_to', provider='Gemini')}", flush=True)
+ marker, state = comm._send_message(message)
+ comm._remember_gemini_session(state.get("session_path") or comm.log_reader.current_session_path())
+
+ # Pure sync wait (default 1 hour, configurable via CCB_SYNC_TIMEOUT)
+ sync_timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0"))
+ message_reply, new_state = comm.log_reader.wait_for_message(state, sync_timeout)
+ state = new_state or state
+ comm._remember_gemini_session(state.get("session_path") or comm.log_reader.current_session_path())
+
+ # Save to cache
+ cache_dir = Path.home() / ".cache" / "ccb"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ reply_file = cache_dir / "gemini_last_reply.txt"
+
+ if message_reply:
+ print(f"🤖 {t('reply_from', provider='Gemini')}")
+ print(message_reply)
+ reply_file.write_text(message_reply, encoding="utf-8")
+ else:
+ print(f"⏰ Timeout after {int(sync_timeout)}s")
+ save_pending_state(state)
+ return 0
+ except KeyboardInterrupt:
+ try:
+ save_pending_state(locals().get("state", {}) if isinstance(locals().get("state"), dict) else {})
+ except Exception:
+ pass
+ print("❌ Interrupted", file=sys.stderr)
+ return 130
except Exception as exc:
print(f"❌ {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
- sys.exit(main(sys.argv))
+ raise SystemExit(main(sys.argv))
diff --git a/bin/gpend b/bin/gpend
index 7c463d3..0faf63c 100755
--- a/bin/gpend
+++ b/bin/gpend
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-gpend - 查看 Gemini 最新回复
+gpend - View latest Gemini reply
"""
import sys
@@ -9,25 +9,51 @@ 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
except ImportError as exc:
- print(f"导入失败: {exc}")
+ print(f"Import failed: {exc}")
sys.exit(1)
+def _load_cached_reply() -> str | None:
+ """Load cached reply from background gask-w process"""
+ cache_file = Path.home() / ".cache" / "ccb" / "gemini_last_reply.txt"
+ if not cache_file.exists():
+ return None
+ try:
+ content = cache_file.read_text(encoding="utf-8").strip()
+ if content:
+ cache_file.unlink() # Clear after reading
+ return content
+ except Exception:
+ pass
+ return None
+
+
def main() -> int:
try:
+ # First check cached reply from background gask-w
+ cached = _load_cached_reply()
+ if cached:
+ print(f"🤖 Gemini reply:")
+ print(cached)
+ return 0
+
comm = GeminiCommunicator()
output = comm.consume_pending(display=False)
if output:
print(output)
else:
- print('暂无 Gemini 回复')
+ print(t('no_reply_available', provider='Gemini'))
return 0
except Exception as exc:
- print(f"❌ 执行失败: {exc}")
+ print(f"❌ {t('execution_failed', error=exc)}")
return 1
diff --git a/bin/gping b/bin/gping
index cff918f..d78e07f 100755
--- a/bin/gping
+++ b/bin/gping
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-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"❌ Gemini connectivity test failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
except ImportError as e:
- print(f"❌ 导入模块失败: {e}")
+ print(f"❌ Module import failed: {e}")
sys.exit(1)
diff --git a/ccb b/ccb
index a62e8c4..33fbaba 100755
--- a/ccb
+++ b/ccb
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
-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,155 @@ 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 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 = "2.2"
+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()
@@ -78,68 +213,88 @@ class AILauncher:
self.processes = {}
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
@@ -149,7 +304,7 @@ class AILauncher:
elif provider == "gemini":
return self._start_gemini(tmux_session)
else:
- print(f"❌ 未知的 provider: {provider}")
+ print(f"❌ {t('unknown_provider', provider=provider)}")
return False
def _start_provider_wezterm(self, provider: str) -> bool:
@@ -177,12 +332,12 @@ class AILauncher:
if provider == "codex":
input_fifo = runtime / "input.fifo"
output_fifo = runtime / "output.fifo"
- # WezTerm 模式通过 pane 注入文本,不强依赖 FIFO;Windows/WSL 场景也不一定支持 mkfifo。
+ # 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)
else:
self._write_gemini_session(runtime, None, pane_id=pane_id)
- 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 +345,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 = (
@@ -216,12 +371,12 @@ 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:
self._write_gemini_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,14 +402,17 @@ 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
@@ -265,16 +423,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"),
@@ -290,13 +458,55 @@ class AILauncher:
# Only trust local project state; deleting local dotfiles should reset resume behavior.
project_session = Path.cwd() / ".codex-session"
if project_session.exists():
+ data = self._read_json_file(project_session)
+ cached = data.get("codex_session_id")
+ if isinstance(cached, str) and cached:
+ recorded_norm = _extract_session_work_dir_norm(data)
+ if recorded_norm:
+ work_keys = _work_dir_match_keys(Path.cwd())
+ if not work_keys or recorded_norm in work_keys:
+ return cached, True
+
+ # Fallback: scan Codex session logs for the latest session bound to this cwd.
+ # This handles cases where `.codex-session` exists but never got updated with `codex_session_id`
+ # (e.g., user closed the backend before sending any message through the bridge).
+ 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:
- data = json.loads(project_session.read_text())
- cached = data.get("codex_session_id")
- if isinstance(cached, str) and cached:
- return cached, True
+ with log_path.open("r", encoding="utf-8", errors="ignore") as handle:
+ first = handle.readline().strip()
+ except OSError:
+ continue
+ if not first:
+ continue
+ try:
+ 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:
+ return sid, True
return None, False
def _build_codex_start_cmd(self) -> str:
@@ -306,11 +516,11 @@ class AILauncher:
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 +528,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 +569,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:
@@ -365,6 +595,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,8 +615,8 @@ 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()
@@ -431,7 +663,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 +681,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 +701,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 +713,45 @@ 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):
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)
+ work_dir = Path.cwd()
data.update({
"session_id": self.session_id,
"runtime_dir": str(runtime),
@@ -508,14 +761,28 @@ exec tmux attach -t "$TMUX_SESSION"
"tmux_session": tmux_session,
"pane_id": pane_id,
"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))
+
+ 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_gemini_session(self, runtime, tmux_session, pane_id=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),
@@ -526,7 +793,12 @@ exec tmux attach -t "$TMUX_SESSION"
"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 +866,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 +918,46 @@ exec tmux attach -t "$TMUX_SESSION"
else:
env["GEMINI_TMUX_SESSION"] = self.tmux_sessions.get("gemini", "")
- cmd = ["claude"]
+ try:
+ claude_cmd = self._find_claude_cmd()
+ except FileNotFoundError as e:
+ print(str(e))
+ return 1
+
+ cmd = [claude_cmd]
if self.auto:
cmd.append("--dangerously-skip-permissions")
- local_session_id: str | None = None
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")
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
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()
@@ -686,10 +977,12 @@ exec tmux attach -t "$TMUX_SESSION"
for session_file in [Path.cwd() / ".codex-session", Path.cwd() / ".gemini-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 +990,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)
@@ -720,13 +1013,10 @@ exec tmux attach -t "$TMUX_SESSION"
for provider in providers:
if not self._start_provider(provider):
return 1
- time.sleep(1)
self._warmup_provider(provider)
- time.sleep(2)
-
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 +1032,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
@@ -769,11 +1059,11 @@ def cmd_status(args):
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 +1081,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']}")
@@ -816,11 +1106,11 @@ def cmd_kill(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", "")
@@ -836,9 +1126,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 +1141,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 +1172,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 +1206,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 +1231,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 +1246,121 @@ 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 cmd_version(args):
+ """Show version info and check for updates"""
+ script_root = Path(__file__).resolve().parent
+ default_install_dir = Path.home() / ".local/share/codex-dual"
+ install_dir = Path(os.environ.get("CODEX_INSTALL_PREFIX") or default_install_dir).expanduser()
+ if (script_root / "install.sh").exists():
+ 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")
+
+ return 0
+
+
def cmd_update(args):
"""Update ccb to latest version"""
- import shutil
import urllib.request
import tarfile
import tempfile
@@ -970,6 +1373,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 +1383,71 @@ 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}")
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 +1456,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 +1471,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
@@ -1046,8 +1502,14 @@ def cmd_update(args):
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。")
+ # 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}")
return 0
except Exception as e:
@@ -1060,32 +1522,41 @@ def cmd_update(args):
def main():
- parser = argparse.ArgumentParser(description="Claude AI 统一启动器", add_help=True)
- subparsers = parser.add_subparsers(dest="command", help="子命令")
-
- # 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 主窗口")
-
- # status 子命令
- status_parser = subparsers.add_parser("status", help="检查状态")
- status_parser.add_argument("providers", nargs="*", default=[], help="要检查的后端 (codex/gemini)")
-
- # kill 子命令
- kill_parser = subparsers.add_parser("kill", help="终止会话")
- kill_parser.add_argument("providers", nargs="*", default=[], help="要终止的后端 (codex/gemini)")
-
- # restore 子命令
- restore_parser = subparsers.add_parser("restore", help="恢复/attach 会话")
- restore_parser.add_argument("providers", nargs="*", default=[], help="要恢复的后端 (codex/gemini)")
-
- # update 子命令
- update_parser = subparsers.add_parser("update", help="Update to latest version")
-
- args = parser.parse_args()
+ parser = argparse.ArgumentParser(description="Claude AI unified launcher", add_help=True)
+ subparsers = parser.add_subparsers(dest="command", help="Subcommands")
+
+ # up subcommand
+ up_parser = subparsers.add_parser("up", help="Start AI backends")
+ up_parser.add_argument("providers", nargs="*", choices=["codex", "gemini"], 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 subcommand
+ status_parser = subparsers.add_parser("status", help="Check status")
+ status_parser.add_argument("providers", nargs="*", default=[], help="Backends to check (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)")
+
+ # 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)")
+
+ # update subcommand
+ subparsers.add_parser("update", help="Update to latest version")
+
+ # version subcommand
+ subparsers.add_parser("version", help="Show version and check for updates")
+
+ 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 +1572,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/commands/cask-w.md b/commands/cask-w.md
index 20ee42f..28b5946 100644
--- a/commands/cask-w.md
+++ b/commands/cask-w.md
@@ -1,24 +1,23 @@
-通过 `cask-w` 命令将指令转发到 Codex 会话并等待回复(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。
+Forward commands to Codex session and wait for reply via `cask-w` command (supports tmux / WezTerm).
-执行方式:
-- 沉默运行 `Bash(cask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作
-- 在终端中也可执行 `cask-w <转发内容>`,效果等同
+Execution:
+1. Run `Bash(cask-w "", run_in_background=true)` to start background task
+2. Tell user the task_id and that Codex is processing
+3. Wait for bash-notification (task completed)
+4. When notification arrives, immediately `cat` the output file to show result
-参数说明:
-- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令)
-- ⚠️ 注意:cask-w 转发后会等待 Codex 回复(默认无超时,`Ctrl-C` 可中断)
+Parameters:
+- `` required, will be forwarded to Codex session
-交互流程:
-1. 转发命令到 Codex tmux 会话
-2. 捕获当前日志状态
-3. 等待新的回复消息
-4. 打印回复内容后返回
+Workflow:
+1. Start cask-w in background -> get task_id
+2. Inform user: "Codex processing (task: xxx)"
+3. When bash-notification arrives -> `cat ` to show result
-示例:
-- `Bash(cask-w "pwd")`
-- `Bash(cask-w "echo test")`
+Examples:
+- `Bash(cask-w "analyze code", run_in_background=true)`
+- bash-notification arrives -> `cat /tmp/.../tasks/xxx.output`
-提示:
-- cask-w 会阻塞等待 Codex 回复,适合需要立即看到结果的场景
-- 如不需要等待,使用 `/cask` 即可
-- cask-w 返回后表示已获取回复,无需继续执行任何操作
+Hints:
+- Use `cask` for fire-and-forget (no wait)
+- Use `/cpend` to view latest reply anytime
diff --git a/commands/cask.md b/commands/cask.md
index 75a12e0..d8ec24f 100644
--- a/commands/cask.md
+++ b/commands/cask.md
@@ -1,18 +1,18 @@
-通过 `cask` 命令将指令转发到 Codex 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。
+Forward commands to Codex session via `cask` command (supports tmux / WezTerm, forward only, does not execute in current Claude process).
-执行方式:
-- 沉默运行 `Bash(cask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作
-- 在终端中也可执行 `cask <转发内容>`,效果等同
+Execution:
+- Run silently `Bash(cask "")`, no additional analysis or operations after execution
+- Can also run `cask ` in terminal with same effect
-参数说明:
-- `<转发内容>` 必填,会被转发到 Codex 会话(例如 `pwd`、`ls` 等命令)
-- ⚠️ 注意:cask 仅负责转发,不在本 Claude 进程执行,返回后立即结束
+Parameters:
+- `` required, will be forwarded to Codex session (e.g. `pwd`, `ls` commands)
+- Note: cask only forwards, does not execute in Claude process, returns immediately after
-示例:
+Examples:
- `Bash(cask "pwd")`
- `Bash(cask "ls -la")`
-提示:
-- cask 发送命令后立即返回,不等待结果
-- 如需等待 Codex 回复,请使用 `/cask-w`
-- cask 返回后表示转发完成,无需继续执行任何操作
+Hints:
+- cask returns immediately after sending, does not wait for result
+- Use `/cask-w` if you need to wait for Codex reply
+- After cask returns, forwarding is complete, no further action needed
diff --git a/commands/cpend.md b/commands/cpend.md
index 6f2423a..a514e16 100644
--- a/commands/cpend.md
+++ b/commands/cpend.md
@@ -1,19 +1,19 @@
-使用 `cpend` 从 Codex 官方日志中抓取最新回复,适合异步模式或超时后的补充查询。
+Use `cpend` to fetch latest reply from Codex official logs, suitable for async mode or follow-up queries after timeout.
-执行方式:
-- Claude 端使用 `Bash(cpend)`,命令执行过程保持静默
-- 本地终端可直接运行 `cpend`
+Execution:
+- Use `Bash(cpend)` on Claude side, keep command execution silent
+- Run `cpend` directly in local terminal
-功能特点:
-1. 解析 `.codex-session` 记录的日志路径,定位本次会话的最新 JSONL 文件
-2. 读取尾部消息并返回 Codex 最近一次输出
-3. 若无新内容,将提示“暂无 Codex 回复”
+Features:
+1. Parses log path recorded in `.codex-session`, locates latest JSONL file for current session
+2. Reads tail messages and returns Codex's most recent output
+3. If no new content, will prompt "No Codex reply yet"
-常见场景:
-- `cask` 异步提交多条任务后集中查看结果
-- `cask-w` 因超时退出后手动确认 Codex 是否已回应
-- 需要核对 Codex 回复与原始问题是否匹配
+Common scenarios:
+- View results after submitting multiple tasks via `cask` async
+- Manually confirm if Codex has responded after `cask-w` timeout exit
+- Need to verify if Codex reply matches original question
-提示:
-- 日志文件通常位于 `~/.codex/sessions/.../rollout-.jsonl`
-- 如果命令返回空,请先确认 Codex 会话仍在运行(可用 `/cping` 检查)
+Hints:
+- Log file usually located at `~/.codex/sessions/.../rollout-.jsonl`
+- If command returns empty, first confirm Codex session is still running (use `/cping` to check)
diff --git a/commands/cping.md b/commands/cping.md
index 5b3a5bc..9be1f1c 100644
--- a/commands/cping.md
+++ b/commands/cping.md
@@ -1,19 +1,19 @@
-使用 `cping` 检查当前 Codex 会话是否健康,快速定位通信问题。
+Use `cping` to check if current Codex session is healthy, quickly locate communication issues.
-执行方式:
-- Claude 端运行 `Bash(cping)`,无需输出命令执行过程
-- 本地终端直接执行 `cping`
+Execution:
+- Run `Bash(cping)` on Claude side, no need to output command execution process
+- Run `cping` directly in local terminal
-检测内容:
-1. `.codex-session` 是否标记为活跃,运行目录是否存在
-2. tmux 模式:FIFO 管道是否仍可访问
-3. tmux 模式:Codex 侧进程是否存活(根据 `codex.pid` 验证)
-4. WezTerm 模式:pane 是否仍存在(根据 `wezterm cli list` 检测)
+Detection items:
+1. Is `.codex-session` marked as active, does runtime directory exist
+2. tmux mode: Is FIFO pipe still accessible
+3. tmux mode: Is Codex side process alive (verified by `codex.pid`)
+4. WezTerm mode: Does pane still exist (detected via `wezterm cli list`)
-输出说明:
-- 成功:`✅ Codex连接正常 (...)`
-- 失败:列出缺失的组件或异常信息,便于进一步排查
+Output:
+- Success: `Codex connection OK (...)`
+- Failure: Lists missing components or error info for further troubleshooting
-提示:
-- 若检测失败,可尝试重新运行 `ccb up codex` 或查看桥接日志
-- 在多次超时或无回复时,先执行 `cping` 再决定是否重启会话
+Hints:
+- If detection fails, try re-running `ccb up codex` or check bridge logs
+- On multiple timeouts or no response, run `cping` first before deciding to restart session
diff --git a/commands/gask-w.md b/commands/gask-w.md
index 62e88e3..37fa754 100644
--- a/commands/gask-w.md
+++ b/commands/gask-w.md
@@ -1,18 +1,23 @@
-通过 `gask-w` 命令将指令转发到 Gemini 会话,并同步等待回复(支持 tmux / WezTerm)。
+Forward commands to Gemini session and wait for reply via `gask-w` command (supports tmux / WezTerm).
-执行方式:
-- 沉默运行 `Bash(gask-w "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作
-- 在终端中也可执行 `gask-w <转发内容>`,效果等同
+Execution:
+1. Run `Bash(gask-w "", run_in_background=true)` to start background task
+2. Tell user the task_id and that Gemini is processing
+3. Wait for bash-notification (task completed)
+4. When notification arrives, immediately `cat` the output file to show result
-参数说明:
-- `<转发内容>` 必填,会被转发到 Gemini 会话
-- ⚠️ 注意:gask-w 会等待 Gemini 回复后再返回
+Parameters:
+- `` required, will be forwarded to Gemini session
-示例:
-- `Bash(gask-w "解释一下这段代码")`
-- `Bash(gask-w "这个方案有什么建议?")`
+Workflow:
+1. Start gask-w in background -> get task_id
+2. Inform user: "Gemini processing (task: xxx)"
+3. When bash-notification arrives -> `cat ` to show result
-提示:
-- gask-w 会阻塞等待 Gemini 回复
-- 默认无超时,`Ctrl-C` 可中断(如需非阻塞,使用 `/gask`)
-- 适合需要获取 Gemini 反馈的场景
+Examples:
+- `Bash(gask-w "explain this", run_in_background=true)`
+- bash-notification arrives -> `cat /tmp/.../tasks/xxx.output`
+
+Hints:
+- Use `gask` for fire-and-forget (no wait)
+- Use `/gpend` to view latest reply anytime
diff --git a/commands/gask.md b/commands/gask.md
index 2a0a8cf..f55acf9 100644
--- a/commands/gask.md
+++ b/commands/gask.md
@@ -1,18 +1,18 @@
-通过 `gask` 命令将指令转发到 Gemini 会话(支持 tmux / WezTerm,仅转发,不在当前 Claude 进程执行)。
+Forward commands to Gemini session via `gask` command (supports tmux / WezTerm, forward only, does not execute in current Claude process).
-执行方式:
-- 沉默运行 `Bash(gask "<转发内容>")`,执行完毕后不做其他分析、推理或额外操作
-- 在终端中也可执行 `gask <转发内容>`,效果等同
+Execution:
+- Run silently `Bash(gask "")`, no additional analysis or operations after execution
+- Can also run `gask ` in terminal with same effect
-参数说明:
-- `<转发内容>` 必填,会被转发到 Gemini 会话
-- ⚠️ 注意:gask 仅负责转发,不在本 Claude 进程执行,返回后立即结束
+Parameters:
+- `` required, will be forwarded to Gemini session
+- Note: gask only forwards, does not execute in Claude process, returns immediately after
-示例:
-- `Bash(gask "解释一下这段代码")`
-- `Bash(gask "帮我优化这个函数")`
+Examples:
+- `Bash(gask "explain this code")`
+- `Bash(gask "help me optimize this function")`
-提示:
-- gask 发送命令后立即返回,不等待结果
-- 如需等待 Gemini 回复,请使用 `/gask-w`
-- gask 返回后表示转发完成,无需继续执行任何操作
+Hints:
+- gask returns immediately after sending, does not wait for result
+- Use `/gask-w` if you need to wait for Gemini reply
+- After gask returns, forwarding is complete, no further action needed
diff --git a/commands/gpend.md b/commands/gpend.md
index 86b197b..389c268 100644
--- a/commands/gpend.md
+++ b/commands/gpend.md
@@ -1,9 +1,9 @@
-通过 `gpend` 命令查看 Gemini 最新回复。
+View latest Gemini reply via `gpend` command.
-执行方式:
-- 沉默运行 `Bash(gpend)`,执行完毕后不做其他分析、推理或额外操作
-- 在终端中也可执行 `gpend`,效果等同
+Execution:
+- Run silently `Bash(gpend)`, no additional analysis or operations after execution
+- Can also run `gpend` in terminal with same effect
-提示:
-- 用于查看 gask 异步发送后的回复
-- 或 gask-w 超时后继续获取回复
+Hints:
+- Used to view reply after gask async send
+- Or continue getting reply after gask-w timeout
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/install.ps1 b/install.ps1
index b6fb456..46ddc7f 100644
--- a/install.ps1
+++ b/install.ps1
@@ -1,13 +1,59 @@
-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
+# 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"
@@ -38,30 +84,55 @@ function Require-Python310 {
}
try {
- $version = & $exe @args -c "import sys; print('{}.{}.{}'.format(sys.version_info[0], sys.version_info[1], sys.version_info[2]))"
+ $vinfo = & $exe @args -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}.{v.micro} {v.major} {v.minor}')"
+ $parts = $vinfo.Trim() -split " "
+ $version = $parts[0]
+ $major = [int]$parts[1]
+ $minor = [int]$parts[2]
} catch {
- Write-Host "Failed to query Python version using: $PythonCmd"
+ Write-Host "[ERROR] Failed to query Python version using: $PythonCmd"
exit 1
}
- $verParts = ($version -split "\\.") | Where-Object { $_ }
- if ($verParts.Length -lt 2) {
- Write-Host "❌ Unable to parse Python version: $version"
- 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 +164,45 @@ function Install-Native {
}
}
+ 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", "cping", "cpend", "gask", "gask-w", "gping", "gpend")
+
+ # 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")
diff --git a/install.sh b/install.sh
index 0900572..44f6477 100755
--- a/install.sh
+++ b/install.sh
@@ -6,6 +6,81 @@ 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 环境" ;;
+ wsl1_not_supported)
+ en_msg="WSL 1 does not support FIFO pipes, please upgrade to WSL 2"
+ zh_msg="WSL 1 不支持 FIFO 管道,请升级到 WSL 2" ;;
+ 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 作为终端前端" ;;
+ *)
+ en_msg="$key"
+ zh_msg="$key" ;;
+ esac
+ if [[ "$CCB_LANG_DETECTED" == "zh" ]]; then
+ echo "$zh_msg"
+ else
+ echo "$en_msg"
+ fi
+}
+
SCRIPTS_TO_LINK=(
bin/cask
bin/cask-w
@@ -43,14 +118,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,8 +157,8 @@ 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 "❌ Missing dependency: $cmd"
+ echo " Please install $pkg first, then re-run install.sh"
exit 1
fi
}
@@ -93,14 +168,14 @@ require_python_version() {
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+,请升级后重试"
+ echo "❌ Python version too old: $version"
+ echo " Requires Python 3.10+, please upgrade and retry"
exit 1
fi
echo "✓ Python $version"
}
-# 根据 uname 返回 linux / macos / unknown
+# Return linux / macos / unknown based on uname
detect_platform() {
local name
name="$(uname -s 2>/dev/null || echo unknown)"
@@ -128,23 +203,57 @@ check_wsl_compatibility() {
local ver
ver="$(get_wsl_version)"
if [[ "$ver" == "1" ]]; then
- echo "❌ WSL 1 不支持 FIFO 管道,请升级到 WSL 2"
- echo " 运行: wsl --set-version 2"
+ echo "❌ WSL 1 does not support FIFO pipes, please upgrade to WSL 2"
+ echo " Run: wsl --set-version 2"
exit 1
fi
- echo "✅ 检测到 WSL 2 环境"
+ echo "✅ Detected WSL 2 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 "❌ 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 "⚠️ 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() {
local platform
platform="$(detect_platform)"
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 +270,75 @@ 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 "📦 Installing it2 CLI..."
- # 检查 pip3 是否可用
+ # Check if pip3 is available
if ! command -v pip3 >/dev/null 2>&1; then
- echo "❌ 未找到 pip3,无法自动安装 it2"
- echo " 请手动运行: python3 -m pip install it2"
+ echo "❌ pip3 not found, cannot auto-install it2"
+ echo " Please run manually: python3 -m pip install it2"
return 1
fi
- # 安装 it2
+ # Install it2
if pip3 install it2 --user 2>&1; then
- echo "✅ it2 CLI 安装成功"
+ echo "✅ 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"
echo
- echo "⚠️ it2 可能不在 PATH 中,请添加以下路径到你的 shell 配置文件:"
+ echo "⚠️ 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 "❌ 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 (⌘ + ,)"
+ echo " 3. Select Magic tab"
+ echo " 4. Check \"Enable Python API\""
+ echo " 5. Confirm the warning dialog"
echo "================================================================"
echo
}
@@ -238,34 +347,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 "✓ 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 "✓ 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 "✓ Detected iTerm2 environment (it2 CLI installed)"
+ echo " 💡 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 "🍎 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 +383,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 "✓ 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 "✓ 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 "✓ 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 "✓ 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 "✓ 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 "✓ Detected it2 CLI"
return
fi
- # 7. 检查 tmux
+ # 7. Check tmux
if command -v tmux >/dev/null 2>&1; then
- echo "✓ 检测到 tmux(建议同时安装 WezTerm 以获得更好体验)"
+ echo "✓ 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 "❌ 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 "💡 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
@@ -384,7 +493,7 @@ save_wezterm_config() {
if [[ -n "$wezterm_path" ]]; then
mkdir -p "$HOME/.config/ccb"
echo "CODEX_WEZTERM_BIN=${wezterm_path}" > "$HOME/.config/ccb/env"
- echo "✓ WezTerm 路径已缓存: $wezterm_path"
+ echo "✓ WezTerm path cached: $wezterm_path"
fi
}
@@ -415,6 +524,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 +564,7 @@ install_bin_links() {
local name
name="$(basename "$path")"
if [[ ! -f "$INSTALL_PREFIX/$path" ]]; then
- echo "⚠️ 未找到脚本 $INSTALL_PREFIX/$path,跳过创建链接"
+ echo "⚠️ Script not found $INSTALL_PREFIX/$path, skipping link creation"
continue
fi
chmod +x "$INSTALL_PREFIX/$path"
@@ -441,7 +581,7 @@ install_bin_links() {
rm -f "$BIN_DIR/$legacy"
done
- echo "已在 $BIN_DIR 创建可执行入口"
+ echo "Created executable links in $BIN_DIR"
}
install_claude_commands() {
@@ -454,10 +594,11 @@ 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"
+CCB_START_MARKER=""
+CCB_END_MARKER=""
LEGACY_RULE_MARKER="## Codex 协作规则"
remove_codex_mcp() {
@@ -468,7 +609,7 @@ remove_codex_mcp() {
fi
if ! command -v python3 >/dev/null 2>&1; then
- echo "⚠️ 需要 python3 来检测 MCP 配置"
+ echo "⚠️ python3 required to detect MCP configuration"
return
fi
@@ -493,7 +634,7 @@ except:
" 2>/dev/null)
if [[ "$has_codex_mcp" == "yes" ]]; then
- echo "⚠️ 检测到 codex 相关的 MCP 配置,正在移除以避免冲突..."
+ echo "⚠️ Detected codex-related MCP configuration, removing to avoid conflicts..."
python3 -c "
import json
with open('$claude_config', 'r') as f:
@@ -508,11 +649,11 @@ for proj, cfg in data.get('projects', {}).items():
with open('$claude_config', 'w') as f:
json.dump(data, f, indent=2)
if removed:
- print('已移除以下 MCP 配置:')
+ print('Removed the following MCP configurations:')
for r in removed:
print(f' - {r}')
"
- echo "✅ Codex MCP 配置已清理"
+ echo "✅ Codex MCP configuration cleaned"
fi
}
@@ -520,38 +661,18 @@ install_claude_md_config() {
local claude_md="$HOME/.claude/CLAUDE.md"
mkdir -p "$HOME/.claude"
- # 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 "
-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)',
-]
-for p in patterns:
- content = re.sub(p, '', content, flags=re.DOTALL)
-content = content.rstrip() + '\n'
-with open('$claude_md', 'w', encoding='utf-8') as f:
- f.write(content)
-"
- fi
- fi
-
- cat >> "$claude_md" << 'AI_RULES'
-
+ # 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'
+
## 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 the user message starts with any of: `@codex`, `codex:`, `ask codex`, `let codex`, `/cask-w` then immediately run:
+ - `Bash(cask-w "", run_in_background=true)` then STOP and wait for bash-notification
- If user message is only the prefix (no content), ask a 1-line clarification for what to send.
Trigger conditions (any match):
@@ -560,23 +681,26 @@ Trigger conditions (any match):
- 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`
+- Default ask/collaborate -> `Bash(cask-w "", run_in_background=true)`
+ - When bash-notification arrives (task completed), immediately cat the output file to show result
+ - Do NOT continue with other work until result is shown
+- Send without waiting -> `cask ""` (fire and forget)
+- 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
+- "what does codex think" -> `Bash(cask-w "...", run_in_background=true)`, wait for notification, cat output
+- "ask codex to review this" -> `Bash(cask-w "...", run_in_background=true)`, wait for notification, cat output
+- "is codex alive" -> cping
+- "don't wait for reply" -> cask
+- "view codex reply" -> cpend
## 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 the user message starts with any of: `@gemini`, `gemini:`, `ask gemini`, `let gemini`, `/gask-w` then immediately run:
+ - `Bash(gask-w "", run_in_background=true)` then STOP and wait for bash-notification
- If user message is only the prefix (no content), ask a 1-line clarification for what to send.
Trigger conditions (any match):
@@ -585,17 +709,62 @@ Trigger conditions (any match):
- 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`
+- Default ask/collaborate -> `Bash(gask-w "", run_in_background=true)`
+ - When bash-notification arrives (task completed), immediately cat the output file to show result
+ - Do NOT continue with other work until result is shown
+- Send without waiting -> `gask ""` (fire and forget)
+- 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
+- "what does gemini think" -> `Bash(gask-w "...", run_in_background=true)`, wait for notification, cat output
+- "ask gemini to review this" -> `Bash(gask-w "...", run_in_background=true)`, wait for notification, cat output
+- "is gemini alive" -> gping
+- "don't wait for reply" -> gask
+- "view gemini reply" -> gpend
+
AI_RULES
+ local ccb_content
+ ccb_content="$(cat "$ccb_tmpfile")"
+
+ if [[ -f "$claude_md" ]]; then
+ if grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then
+ echo "Updating existing CCB config block..."
+ python3 -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" "$claude_md" 2>/dev/null; then
+ echo "Removing legacy rules and adding new CCB config block..."
+ python3 -c "
+import re
+with open('$claude_md', 'r', encoding='utf-8') as f:
+ content = f.read()
+patterns = [
+ r'## Codex Collaboration Rules.*?(?=\n## (?!Gemini)|\Z)',
+ r'## Codex 协作规则.*?(?=\n## |\Z)',
+ r'## Gemini Collaboration Rules.*?(?=\n## |\Z)',
+ r'## Gemini 协作规则.*?(?=\n## |\Z)',
+]
+for p in patterns:
+ content = re.sub(p, '', content, flags=re.DOTALL)
+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
echo "Updated AI collaboration rules in $claude_md"
}
@@ -660,23 +829,24 @@ 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
+ confirm_backend_env_wsl
require_command python3 python3
require_python_version
require_terminal_backend
if ! has_wezterm; then
echo
echo "================================================================"
- echo "⚠️ 建议安装 WezTerm 作为终端前端(体验更好,推荐 WSL2/Windows 用户)"
- echo " - 官网: https://wezfurlong.org/wezterm/"
- echo " - 优势: 更顺滑的分屏/滚动/字体渲染,WezTerm 模式下桥接更稳定"
+ echo "⚠️ 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
@@ -691,12 +861,12 @@ install_all() {
install_claude_commands
install_claude_md_config
install_settings_permissions
- echo "✅ 安装完成"
- echo " 项目目录 : $INSTALL_PREFIX"
- echo " 可执行目录: $BIN_DIR"
- echo " Claude 命令已更新"
- echo " 全局 CLAUDE.md 已配置 Codex 协作规则"
- echo " 全局 settings.json 已添加权限"
+ echo "✅ Installation complete"
+ echo " Project dir : $INSTALL_PREFIX"
+ echo " Executable dir : $BIN_DIR"
+ echo " Claude commands updated"
+ echo " Global CLAUDE.md configured with Codex collaboration rules"
+ echo " Global settings.json permissions added"
}
uninstall_claude_md_config() {
@@ -706,16 +876,32 @@ 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 grep -q "$CCB_START_MARKER" "$claude_md" 2>/dev/null; then
+ echo "Removing CCB config block from CLAUDE.md..."
+ if command -v python3 >/dev/null 2>&1; then
+ python3 -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 "⚠️ python3 required to clean CLAUDE.md, please manually remove CCB_CONFIG block"
+ fi
+ elif grep -qE "$LEGACY_RULE_MARKER|## Codex Collaboration Rules|## Gemini" "$claude_md" 2>/dev/null; then
+ echo "Removing legacy collaboration rules from CLAUDE.md..."
if command -v python3 >/dev/null 2>&1; then
python3 -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 Collaboration Rules.*?(?=\n## (?!Gemini)|\Z)',
r'## Codex 协作规则.*?(?=\n## |\Z)',
r'## Gemini Collaboration Rules.*?(?=\n## |\Z)',
r'## Gemini 协作规则.*?(?=\n## |\Z)',
@@ -726,9 +912,9 @@ 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 "⚠️ python3 required to clean CLAUDE.md, please manually remove collaboration rules"
fi
fi
}
@@ -761,7 +947,7 @@ uninstall_settings_permissions() {
done
if [[ $has_perms -eq 1 ]]; then
- echo "正在移除 settings.json 中的权限配置..."
+ echo "Removing permission configuration from settings.json..."
python3 -c "
import json
perms_to_remove = [
@@ -784,23 +970,23 @@ if 'permissions' in data and 'allow' in data['permissions']:
with open('$settings_file', 'w') as f:
json.dump(data, f, indent=2)
"
- echo "已移除 settings.json 中的权限配置"
+ echo "Removed permission configuration from settings.json"
fi
else
- echo "⚠️ 需要 python3 来清理 settings.json,请手动移除相关权限"
+ echo "⚠️ python3 required to clean settings.json, please manually remove related permissions"
fi
}
uninstall_all() {
- echo "🧹 开始卸载 ccb..."
+ echo "🧹 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 +997,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 "✅ Uninstall complete"
+ echo " 💡 Note: Dependencies (python3, tmux, wezterm, it2) were not removed"
}
main() {
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/codex_comm.py b/lib/codex_comm.py
index 89237d9..2f6063f 100644
--- a/lib/codex_comm.py
+++ b/lib/codex_comm.py
@@ -1,7 +1,7 @@
#!/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,6 +9,7 @@
import json
import os
import re
+import sys
import time
import shlex
from datetime import datetime
@@ -16,6 +17,10 @@
from typing import Optional, Tuple, Dict, Any
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()
SESSION_ROOT = Path(os.environ.get("CODEX_SESSION_ROOT") or (Path.home() / ".codex" / "sessions")).expanduser()
SESSION_ID_PATTERN = re.compile(
@@ -25,7 +30,7 @@
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):
self.root = Path(root).expanduser()
@@ -59,8 +64,6 @@ def _scan_latest(self) -> Optional[Path]:
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:
- continue
mtime = p.stat().st_mtime
except OSError:
continue
@@ -74,37 +77,52 @@ def _scan_latest(self) -> Optional[Path]:
def _latest_log(self) -> Optional[Path]:
preferred = self._preferred_log
- if preferred and preferred.exists():
- return preferred
+ # Always scan for latest to detect if preferred is stale
latest = self._scan_latest()
if latest:
- self._preferred_log = latest
- return latest
+ # If preferred is stale (different file or older), update it
+ if not preferred or not preferred.exists() or latest != preferred:
+ try:
+ preferred_mtime = preferred.stat().st_mtime if preferred and preferred.exists() else 0
+ latest_mtime = latest.stat().st_mtime
+ if latest_mtime > preferred_mtime:
+ self._preferred_log = latest
+ return latest
+ except OSError:
+ self._preferred_log = latest
+ return latest
+ return preferred if preferred and preferred.exists() else latest
+ return preferred if preferred and preferred.exists() else 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 latest_message(self) -> Optional[str]:
- """直接获取最新一条回复"""
+ """Get the latest reply directly"""
log_path = self._latest_log()
if not log_path or not log_path.exists():
return None
@@ -140,7 +158,9 @@ def latest_message(self) -> Optional[str]:
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 +177,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 +188,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 +237,13 @@ def ensure_log() -> Path:
if latest and latest != log_path:
current_path = latest
self._preferred_log = latest
- try:
- offset = latest.stat().st_size
- except OSError:
- offset = 0
+ # 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(0.05)
+ time.sleep(self._poll_interval)
last_rescan = time.time()
continue
last_rescan = time.time()
@@ -231,12 +275,12 @@ def _extract_message(entry: dict) -> Optional[str]:
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"])
@@ -247,21 +291,40 @@ def __init__(self):
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()
+ # Lazy initialization: defer log reader and health check
+ self._log_reader: Optional[CodexLogReader] = None
+ self._log_reader_primed = False
- healthy, msg = self._check_session_health()
- if not healthy:
- raise RuntimeError(f"❌ 会话不健康: {msg}\n提示: 请运行 ccb up codex 启动新会话")
+ 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 _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":
@@ -284,7 +347,7 @@ def _load_session_info(self):
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 +367,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 +379,52 @@ 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 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 +437,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 +454,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,29 +487,29 @@ 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):
@@ -444,7 +520,7 @@ def consume_pending(self, display: bool = True):
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 +528,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 +570,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
@@ -527,7 +603,13 @@ 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)
@@ -579,13 +661,13 @@ 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", action="store_true", help="Show pending reply")
args = parser.parse_args()
@@ -596,7 +678,7 @@ 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:
@@ -607,18 +689,18 @@ def main() -> int:
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..1da8876 100644
--- a/lib/codex_dual_bridge.py
+++ b/lib/codex_dual_bridge.py
@@ -1,7 +1,7 @@
#!/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 +18,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 +44,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 +58,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 +67,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 +106,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 +120,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 +136,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 +159,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/gemini_comm.py b/lib/gemini_comm.py
index 305cfb9..cad5c48 100755
--- a/lib/gemini_comm.py
+++ b/lib/gemini_comm.py
@@ -1,7 +1,7 @@
#!/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
@@ -14,15 +14,19 @@
from typing import Optional, Tuple, Dict, Any
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()
@@ -57,7 +61,7 @@ def _chats_dir(self) -> Optional[Path]:
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 +89,33 @@ def _scan_latest_session(self) -> Optional[Path]:
if sessions:
return sessions[-1]
- # fallback: projectHash 可能因路径规范化差异(Windows/WSL、符号链接等)而不匹配
+ # fallback: projectHash may mismatch due to path normalization differences (Windows/WSL, symlinks, etc.)
return self._scan_latest_session_any_project()
def _latest_session(self) -> Optional[Path]:
- if self._preferred_session and self._preferred_session.exists():
- return self._preferred_session
+ preferred = self._preferred_session
+ # Always scan for latest to detect if preferred is stale
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
+ # If preferred is stale (different file or older), update it
+ if not preferred or not preferred.exists() or latest != preferred:
+ try:
+ preferred_mtime = preferred.stat().st_mtime if preferred and preferred.exists() else 0
+ latest_mtime = latest.stat().st_mtime
+ if latest_mtime > preferred_mtime:
+ self._preferred_session = latest
+ try:
+ project_hash = latest.parent.parent.name
+ if project_hash:
+ self._project_hash = project_hash
+ except Exception:
+ pass
+ return latest
+ except OSError:
+ self._preferred_session = latest
+ return latest
+ return preferred
+ return preferred if preferred and preferred.exists() else None
def set_preferred_session(self, session_path: Optional[Path]) -> None:
if not session_path:
@@ -117,7 +131,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 +140,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 +184,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
@@ -177,6 +210,7 @@ def latest_message(self) -> Optional[str]:
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 +219,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 +260,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 +284,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 +447,52 @@ 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 _prime_log_binding(self) -> None:
session_path = self.log_reader.current_session_path()
@@ -373,7 +503,7 @@ 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":
@@ -416,49 +546,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,28 +607,28 @@ 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):
@@ -499,7 +638,7 @@ def consume_pending(self, display: bool = True):
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 +690,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 +708,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 +728,13 @@ 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", action="store_true", help="View pending reply")
args = parser.parse_args()
@@ -597,7 +745,7 @@ 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:
@@ -605,18 +753,18 @@ def main() -> int:
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..9d5332b
--- /dev/null
+++ b/lib/i18n.py
@@ -0,0 +1,245 @@
+"""
+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/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",
+ "wsl_not_supported": "WSL 1 does not support FIFO pipes, please upgrade to WSL 2",
+ "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/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} 环境",
+ "wsl_not_supported": "WSL 1 不支持 FIFO 管道,请升级到 WSL 2",
+ "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/session_utils.py b/lib/session_utils.py
new file mode 100644
index 0000000..3b3b3a7
--- /dev/null
+++ b/lib/session_utils.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""
+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
+ try:
+ file_stat = session_file.stat()
+ file_uid = file_stat.st_uid
+ current_uid = os.getuid()
+
+ if 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)
diff --git a/lib/terminal.py b/lib/terminal.py
index 5068d6d..4e49ec4 100644
--- a/lib/terminal.py
+++ b/lib/terminal.py
@@ -13,6 +13,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"
@@ -25,7 +36,7 @@ def is_wsl() -> bool:
def _load_cached_wezterm_bin() -> str | None:
- """读取安装时缓存的 WezTerm 路径"""
+ """Load cached WezTerm path from installation"""
config = Path.home() / ".config/ccb/env"
if config.exists():
try:
@@ -43,11 +54,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 +82,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 +110,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 +143,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 +171,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 +189,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 +207,7 @@ 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 +226,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, encoding='utf-8', errors='replace', check=True)
+ # 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
@@ -269,34 +286,34 @@ def _bin(cls) -> str:
return cls._wezterm_bin
def send_text(self, pane_id: str, text: str) -> None:
- sanitized = text.replace("\r", "").strip()
+ sanitized = text.replace("\r", "").replace("\n", "").strip()
if not sanitized:
return
- # tmux 可单独发 Enter 键;wezterm cli 没有 send-key,只能用 send-text 发送控制字符。
- # 经验上,很多交互式 CLI 在“粘贴/多行输入”里不会自动执行;这里将文本和 Enter 分两次发送更可靠。
subprocess.run(
- [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"],
- input=sanitized.encode("utf-8"),
+ [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste", sanitized],
check=True,
)
- # 给 TUI 一点时间退出“粘贴/突发输入”路径,再发送 Enter 更像真实按键
- time.sleep(0.01)
- 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,
- )
+ enter_delay = _env_float("CCB_WEZTERM_ENTER_DELAY", 0.01)
+ if enter_delay:
+ time.sleep(enter_delay)
+ for char in ["\r", "\n", "\r\n"]:
+ try:
+ subprocess.run(
+ [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste", char],
+ check=True,
+ )
+ return
+ except subprocess.CalledProcessError:
+ continue
+ subprocess.run(
+ [*self._cli_base_args(), "send-text", "--pane-id", pane_id, "--no-paste"],
+ input=b"\r",
+ check=False,
+ )
def is_alive(self, pane_id: str) -> bool:
try:
- result = subprocess.run([*self._cli_base_args(), "list", "--format", "json"], capture_output=True, text=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 False
panes = json.loads(result.stdout)
@@ -312,7 +329,9 @@ 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"
+ 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)
@@ -320,7 +339,8 @@ def create_pane(self, cmd: str, cwd: str, direction: str = "right", percent: int
wsl_cwd = wsl_localhost_match.group(1).replace('\\', '/')
elif "\\" 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)
wsl_cwd = result.stdout.strip()
except Exception:
pass
@@ -347,28 +367,31 @@ 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:
+ result = subprocess.run(args, capture_output=True, text=True, encoding='utf-8', errors='replace', check=True)
+ 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"):