diff --git a/.env.example b/.env.example
index d36e2b0e..47aeffda 100644
--- a/.env.example
+++ b/.env.example
@@ -7,8 +7,18 @@ ALLOWED_USERS=123456789,987654321
# Tmux session name (optional, defaults to "ccbot")
TMUX_SESSION_NAME=ccbot
+# Runtime (Claude Code or Codex) to start in new windows: claude | codex
+CCBOT_RUNTIME=claude
+
# Claude command to run in new windows (optional, defaults to "claude")
CLAUDE_COMMAND=claude
+# Codex command to run in new windows when CCBOT_RUNTIME=codex
+CODEX_COMMAND=codex --no-alt-screen
+# ccbot auto-appends: --enable codex_hooks --enable default_mode_request_user_input
+
+# Codex home directory (used for hooks.json and native session transcripts)
+CODEX_HOME=
+
# Monitor polling interval in seconds (optional, defaults to 2.0)
MONITOR_POLL_INTERVAL=2.0
diff --git a/README.md b/README.md
index e7ee01dc..1ac8d235 100644
--- a/README.md
+++ b/README.md
@@ -1,59 +1,53 @@
# CCBot
-[中文文档](README_CN.md)
+[中文文档](README_CN.md)
[Русская документация](README_RU.md)
-Control Claude Code sessions remotely via Telegram — monitor, interact, and manage AI coding sessions running in tmux.
+Control Claude Code or Codex sessions from Telegram while they keep running in tmux.
https://github.com/user-attachments/assets/15ffb38e-5eb9-4720-93b9-412e4961dc93
-## Why CCBot?
+## What It Is
-Claude Code runs in your terminal. When you step away from your computer — commuting, on the couch, or just away from your desk — the session keeps working, but you lose visibility and control.
+CCBot is a thin Telegram control layer on top of tmux.
-CCBot solves this by letting you **seamlessly continue the same session from Telegram**. The key insight is that it operates on **tmux**, not the Claude Code SDK. Your Claude Code process stays exactly where it is, in a tmux window on your machine. CCBot simply reads its output and sends keystrokes to it. This means:
+- Your Claude Code or Codex process keeps running in a tmux window.
+- Telegram lets you watch output, reply, and send commands remotely.
+- You can always switch back to the terminal with `tmux attach`.
-- **Switch from desktop to phone mid-conversation** — Claude is working on a refactor? Walk away, keep monitoring and responding from Telegram.
-- **Switch back to desktop anytime** — Since the tmux session was never interrupted, just `tmux attach` and you're back in the terminal with full scrollback and context.
-- **Run multiple sessions in parallel** — Each Telegram topic maps to a separate tmux window, so you can juggle multiple projects from one chat group.
-
-Other Telegram bots for Claude Code typically wrap the Claude Code SDK to create separate API sessions. Those sessions are isolated — you can't resume them in your terminal. CCBot takes a different approach: it's just a thin control layer over tmux, so the terminal remains the source of truth and you never lose the ability to switch back.
-
-In fact, CCBot itself was built this way — iterating on itself through Claude Code sessions monitored and driven from Telegram via CCBot.
+Each Telegram topic maps to one tmux window and one Claude Code or Codex session.
## Features
-- **Topic-based sessions** — Each Telegram topic maps 1:1 to a tmux window and Claude session
-- **Real-time notifications** — Get Telegram messages for assistant responses, thinking content, tool use/result, and local command output
-- **Interactive UI** — Navigate AskUserQuestion, ExitPlanMode, and Permission Prompts via inline keyboard
-- **Voice messages** — Voice messages are transcribed via OpenAI and forwarded as text
-- **Send messages** — Forward text to Claude Code via tmux keystrokes
-- **Slash command forwarding** — Send any `/command` directly to Claude Code (e.g. `/clear`, `/compact`, `/cost`)
-- **Create new sessions** — Start Claude Code sessions from Telegram via directory browser
-- **Resume sessions** — Pick up where you left off by resuming an existing Claude session in a directory
-- **Kill sessions** — Close a topic to auto-kill the associated tmux window
-- **Message history** — Browse conversation history with pagination (newest first)
-- **Hook-based session tracking** — Auto-associates tmux windows with Claude sessions via `SessionStart` hook
-- **Persistent state** — Thread bindings and read offsets survive restarts
+- One Telegram topic per session
+- Real-time replies and status updates
+- Resume old sessions from Telegram
+- Voice message transcription
+- Inline UI for prompts and selections
+- Message history and screenshots
+- Works with Claude Code or Codex
## Prerequisites
-- **tmux** — must be installed and available in PATH
-- **Claude Code** — the CLI tool (`claude`) must be installed
+- `tmux`
+- `claude` or `codex`
+- A Telegram bot created with [@BotFather](https://t.me/BotFather)
-## Installation
+## Install
-### Option 1: Install from GitHub (Recommended)
+### Install from GitHub
```bash
-# Using uv (recommended)
uv tool install git+https://github.com/six-ddc/ccmux.git
+```
-# Or using pipx
+Or:
+
+```bash
pipx install git+https://github.com/six-ddc/ccmux.git
```
-### Option 2: Install from source
+### Install from source
```bash
git clone https://github.com/six-ddc/ccmux.git
@@ -61,7 +55,7 @@ cd ccmux
uv sync
```
-## Configuration
+## Basic Config
**1. Create a Telegram bot and enable Threaded Mode:**
@@ -79,208 +73,179 @@ TELEGRAM_BOT_TOKEN=your_bot_token_here
ALLOWED_USERS=your_telegram_user_id
```
-**Required:**
+You can also keep a more complete manual config like this:
+
+```ini
+TELEGRAM_BOT_TOKEN=your_bot_token_here
+ALLOWED_USERS=123456789
+
+# Default runtime for new bot processes: claude or codex
+CCBOT_RUNTIME=claude
+
+# Commands used when opening new tmux windows
+CLAUDE_COMMAND=claude
+CODEX_COMMAND=codex --no-alt-screen
+
+# Optional
+TMUX_SESSION_NAME=ccbot
+OPENAI_API_KEY=
+OPENAI_BASE_URL=https://api.openai.com/v1
+```
+
+Useful optional settings:
-| Variable | Description |
-| -------------------- | --------------------------------- |
-| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather |
-| `ALLOWED_USERS` | Comma-separated Telegram user IDs |
+| Variable | Default | Description |
+| --- | --- | --- |
+| `CCBOT_RUNTIME` | `claude` | Which CLI to start in new windows: `claude` or `codex` |
+| `CCBOT_DIR` | `~/.ccbot` | Config and state directory |
+| `CLAUDE_COMMAND` | `claude` | Claude Code command |
+| `CODEX_COMMAND` | `codex --no-alt-screen` | Codex command |
+| `CODEX_HOME` | `~/.codex` | Codex config/session directory |
+| `TMUX_SESSION_NAME` | `ccbot` | tmux session name |
+| `MONITOR_POLL_INTERVAL` | `2.0` | Session monitor polling interval in seconds |
+| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden directories in the picker |
+| `OPENAI_API_KEY` | _(empty)_ | Needed only for voice transcription |
+| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Optional OpenAI-compatible base URL |
-**Optional:**
+Notes:
-| Variable | Default | Description |
-| ----------------------- | ---------- | ------------------------------------------------ |
-| `CCBOT_DIR` | `~/.ccbot` | Config/state directory (`.env` loaded from here) |
-| `TMUX_SESSION_NAME` | `ccbot` | Tmux session name |
-| `CLAUDE_COMMAND` | `claude` | Command to run in new windows |
-| `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds |
-| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser |
-| `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription |
-| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API base URL (for proxies or compatible APIs) |
+- `ALLOWED_USERS` accepts one or more Telegram user IDs separated by commas.
+- `ccbot --run claude|codex` only affects the current startup and does not rewrite your `.env`.
+- If you installed from source, you can also keep a project-local `.env` for testing, but `~/.ccbot/.env` is the normal long-term setup.
-Message formatting is always HTML via `chatgpt-md-converter` (`chatgpt_md_converter` package).
-There is no runtime formatter switch to MarkdownV2.
+If you run on a VPS with no interactive terminal for approvals, you may want a
+less interactive Claude Code command, for example:
-> If running on a VPS where there's no interactive terminal to approve permissions, consider:
->
-> ```
-> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
-> ```
+```bash
+CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
+```
-## Hook Setup (Recommended)
+## Hook Setup
-Auto-install via CLI:
+Install hooks with:
```bash
ccbot hook --install
```
-Or manually add to `~/.claude/settings.json`:
+If you want to install both Claude Code and Codex hooks explicitly:
+
+```bash
+ccbot hook --install --run all
+```
+
+Codex hooks are still experimental in `v0.114.0`. If you start Codex manually
+outside CCBot, enable them with:
+
+```bash
+codex --enable codex_hooks
+```
+
+If you prefer to configure hooks manually instead of using `ccbot hook --install`:
+
+Claude Code `~/.claude/settings.json`:
```json
{
"hooks": {
"SessionStart": [
{
- "hooks": [{ "type": "command", "command": "ccbot hook", "timeout": 5 }]
+ "hooks": [
+ {
+ "type": "command",
+ "command": "ccbot hook",
+ "timeout": 5
+ }
+ ]
}
]
}
}
```
-This writes window-session mappings to `$CCBOT_DIR/session_map.json` (`~/.ccbot/` by default), so the bot automatically tracks which Claude session is running in each tmux window — even after `/clear` or session restarts.
-
-## Usage
+Codex `~/.codex/hooks.json`:
-```bash
-# If installed via uv tool / pipx
-ccbot
-
-# If installed from source
-uv run ccbot
+```json
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "ccbot hook",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+}
```
-### Commands
-
-**Bot commands:**
-
-| Command | Description |
-| ------------- | ------------------------------- |
-| `/start` | Show welcome message |
-| `/history` | Message history for this topic |
-| `/screenshot` | Capture terminal screenshot |
-| `/esc` | Send Escape to interrupt Claude |
-
-**Claude Code commands (forwarded via tmux):**
-
-| Command | Description |
-| ---------- | ---------------------------- |
-| `/clear` | Clear conversation history |
-| `/compact` | Compact conversation context |
-| `/cost` | Show token/cost usage |
-| `/help` | Show Claude Code help |
-| `/memory` | Edit CLAUDE.md |
-
-Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/review`, `/doctor`, `/init`).
-
-### Topic Workflow
-
-**1 Topic = 1 Window = 1 Session.** The bot runs in Telegram Forum (topics) mode.
-
-**Creating a new session:**
-
-1. Create a new topic in the Telegram group
-2. Send any message in the topic
-3. A directory browser appears — select the project directory
-4. If the directory has existing Claude sessions, a session picker appears — choose one to resume or start fresh
-5. A tmux window is created, `claude` starts (with `--resume` if resuming), and your pending message is forwarded
-
-**Sending messages:**
-
-Once a topic is bound to a session, just send text or voice messages in that topic — text gets forwarded to Claude Code via tmux keystrokes, and voice messages are automatically transcribed and forwarded as text.
-
-**Killing a session:**
-
-Close (or delete) the topic in Telegram. The associated tmux window is automatically killed and the binding is removed.
-
-### Message History
+This lets CCBot keep `session_map.json` updated so tmux windows stay linked to
+the right Claude Code or Codex session.
-Navigate with inline buttons:
+## Start The Bot
+```bash
+ccbot
```
-📋 [project-name] Messages (42 total)
-
-───── 14:32 ─────
-👤 fix the login bug
+If installed from source:
-───── 14:33 ─────
+```bash
+uv run ccbot
+```
-I'll look into the login bug...
+To use Codex just for this bot process:
-[◀ Older] [2/9] [Newer ▶]
+```bash
+ccbot --run codex
```
-### Notifications
+## Daily Use
-The monitor polls session JSONL files every 2 seconds and sends notifications for:
+1. Create a new topic in your Telegram group.
+2. Send any message in that topic.
+3. Pick a project directory.
+4. Resume an old session or start a new one.
+5. Keep chatting in that topic.
-- **Assistant responses** — Claude's text replies
-- **Thinking content** — Shown as expandable blockquotes
-- **Tool use/result** — Summarized with stats (e.g. "Read 42 lines", "Found 5 matches")
-- **Local command output** — stdout from commands like `git status`, prefixed with `❯ command_name`
+Text and voice messages are forwarded to the linked Claude Code or Codex
+session. Closing the topic closes the linked tmux window.
-Notifications are delivered to the topic bound to the session's window.
+## Commands
-Formatting note:
-- Telegram messages are rendered with parse mode `HTML` using `chatgpt-md-converter`
-- Long messages are split with HTML tag awareness to preserve code blocks and formatting
+| Command | Description |
+| --- | --- |
+| `/start` | Show the welcome message |
+| `/history` | Show message history for the current topic |
+| `/screenshot` | Capture the terminal as an image |
+| `/esc` | Send Escape to interrupt the current session |
+| `/unbind` | Unbind this topic but keep the tmux window running |
+| `/kill` | End the session for this topic |
-## Running Claude Code in tmux
+Most other `/...` commands are forwarded directly to the current runtime.
-### Option 1: Create via Telegram (Recommended)
+- Claude Code menu includes commands such as `/usage`, `/help`, `/memory`, and `/model`.
+- Codex menu includes commands such as `/status` and `/plan`.
+- You can still type other runtime commands manually, for example `/clear`, `/compact`, `/review`, or `/model`.
-1. Create a new topic in the Telegram group
-2. Send any message
-3. Select the project directory from the browser
+## Manual tmux Use
-### Option 2: Create Manually
+If you want to create a window yourself:
```bash
tmux attach -t ccbot
tmux new-window -n myproject -c ~/Code/myproject
-# Then start Claude Code in the new window
claude
```
-The window must be in the `ccbot` tmux session (configurable via `TMUX_SESSION_NAME`). The hook will automatically register it in `session_map.json` when Claude starts.
-
-## Data Storage
-
-| Path | Description |
-| ------------------------------- | ----------------------------------------------------------------------- |
-| `$CCBOT_DIR/state.json` | Thread bindings, window states, display names, and per-user read offsets |
-| `$CCBOT_DIR/session_map.json` | Hook-generated `{tmux_session:window_id: {session_id, cwd, window_name}}` mappings |
-| `$CCBOT_DIR/monitor_state.json` | Monitor byte offsets per session (prevents duplicate notifications) |
-| `~/.claude/projects/` | Claude Code session data (read-only) |
+Or with Codex:
-## File Structure
-
-```
-src/ccbot/
-├── __init__.py # Package entry point
-├── main.py # CLI dispatcher (hook subcommand + bot bootstrap)
-├── hook.py # Hook subcommand for session tracking (+ --install)
-├── config.py # Configuration from environment variables
-├── bot.py # Telegram bot setup, command handlers, topic routing
-├── session.py # Session management, state persistence, message history
-├── session_monitor.py # JSONL file monitoring (polling + change detection)
-├── monitor_state.py # Monitor state persistence (byte offsets)
-├── transcript_parser.py # Claude Code JSONL transcript parsing
-├── terminal_parser.py # Terminal pane parsing (interactive UI + status line)
-├── html_converter.py # Markdown → Telegram HTML conversion + HTML-aware splitting
-├── screenshot.py # Terminal text → PNG image with ANSI color support
-├── transcribe.py # Voice-to-text transcription via OpenAI API
-├── utils.py # Shared utilities (atomic JSON writes, JSONL helpers)
-├── tmux_manager.py # Tmux window management (list, create, send keys, kill)
-├── fonts/ # Bundled fonts for screenshot rendering
-└── handlers/
- ├── __init__.py # Handler module exports
- ├── callback_data.py # Callback data constants (CB_* prefixes)
- ├── directory_browser.py # Directory browser inline keyboard UI
- ├── history.py # Message history pagination
- ├── interactive_ui.py # Interactive UI handling (AskUser, ExitPlan, Permissions)
- ├── message_queue.py # Per-user message queue + worker (merge, rate limit)
- ├── message_sender.py # safe_reply / safe_edit / safe_send helpers
- ├── response_builder.py # Response message building (format tool_use, thinking, etc.)
- └── status_polling.py # Terminal status line polling
+```bash
+tmux attach -t ccbot
+tmux new-window -n myproject -c ~/Code/myproject
+codex --no-alt-screen --enable codex_hooks --enable default_mode_request_user_input
```
-
-## Contributors
-
-Thanks to all the people who contribute! We encourage using Claude Code to collaborate on contributions.
-
-
-
-
diff --git a/README_CN.md b/README_CN.md
index c8bd8bd3..4c66d448 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,56 +1,50 @@
# CCBot
-通过 Telegram 远程控制 Claude Code 会话 — 监控、交互、管理运行在 tmux 中的 AI 编程会话。
+通过 Telegram 远程控制运行在 tmux 里的 Claude Code 或 Codex 会话。
https://github.com/user-attachments/assets/15ffb38e-5eb9-4720-93b9-412e4961dc93
-## 为什么做 CCBot?
+## 它是什么
-Claude Code 运行在终端里。当你离开电脑 — 通勤路上、躺在沙发上、或者只是不在工位 — 会话仍在继续,但你失去了查看和控制的能力。
+CCBot 本质上是 tmux 之上的一个 Telegram 控制层。
-CCBot 让你**通过 Telegram 无缝接管同一个会话**。核心设计思路是:它操作的是 **tmux**,而不是 Claude Code SDK。你的 Claude Code 进程始终在 tmux 窗口里运行,CCBot 只是读取它的输出并向它发送按键。这意味着:
+- Claude Code 或 Codex 进程继续跑在 tmux 窗口里
+- 你通过 Telegram 看输出、发消息、下命令
+- 随时可以用 `tmux attach` 回到终端
-- **从电脑无缝切换到手机** — Claude 正在执行重构?走开就是了,继续在 Telegram 上监控和回复。
-- **随时切换回电脑** — tmux 会话从未中断,直接 `tmux attach` 就能回到终端,完整的滚动历史和上下文都在。
-- **并行运行多个会话** — 每个 Telegram 话题对应一个独立的 tmux 窗口,一个聊天组里就能管理多个项目。
+每个 Telegram 话题对应一个 tmux 窗口,以及一个 Claude Code 或 Codex 会话。
-市面上其他 Claude Code Telegram Bot 通常封装 Claude Code SDK 来创建独立的 API 会话,这些会话是隔离的 — 你无法在终端里恢复它们。CCBot 采取了不同的方式:它只是 tmux 之上的一个薄控制层,终端始终是数据源,你永远不会失去切换回去的能力。
+## 功能
-实际上,CCBot 自身就是用这种方式开发的 — 通过 CCBot 在 Telegram 上监控和驱动 Claude Code 会话来迭代自身。
-
-## 功能特性
-
-- **基于话题的会话** — 每个 Telegram 话题 1:1 映射到一个 tmux 窗口和 Claude 会话
-- **实时通知** — 接收助手回复、思考过程、工具调用/结果、本地命令输出的 Telegram 消息
-- **交互式 UI** — 通过内联键盘操作 AskUserQuestion、ExitPlanMode 和权限提示
-- **语音消息** — 语音消息通过 OpenAI 转录为文字并转发
-- **发送消息** — 通过 tmux 按键将文字转发给 Claude Code
-- **斜杠命令转发** — 任何 `/command` 直接发送给 Claude Code(如 `/clear`、`/compact`、`/cost`)
-- **创建新会话** — 通过目录浏览器从 Telegram 启动 Claude Code 会话
-- **恢复会话** — 选择目录中已有的 Claude 会话继续上次的工作
-- **关闭会话** — 关闭话题自动终止关联的 tmux 窗口
-- **消息历史** — 分页浏览对话历史(默认显示最新)
-- **Hook 会话追踪** — 通过 `SessionStart` hook 自动关联 tmux 窗口与 Claude 会话
-- **持久化状态** — 话题绑定和读取偏移量在重启后保持
+- 一个话题对应一个会话
+- 实时回复和状态更新
+- 可以从 Telegram 恢复旧会话
+- 支持语音转文字
+- 支持交互式按钮
+- 支持消息历史和截图
+- 同时支持 Claude Code 和 Codex
## 前置要求
-- **tmux** — 需要安装并在 PATH 中可用
-- **Claude Code** — CLI 工具(`claude`)需要已安装
+- `tmux`
+- `claude` 或 `codex`
+- 一个通过 [@BotFather](https://t.me/BotFather) 创建的 Telegram Bot
## 安装
-### 方式一:从 GitHub 安装(推荐)
+### 从 GitHub 安装
```bash
-# 使用 uv(推荐)
uv tool install git+https://github.com/six-ddc/ccmux.git
+```
-# 或使用 pipx
+或者:
+
+```bash
pipx install git+https://github.com/six-ddc/ccmux.git
```
-### 方式二:从源码安装
+### 从源码安装
```bash
git clone https://github.com/six-ddc/ccmux.git
@@ -58,7 +52,7 @@ cd ccmux
uv sync
```
-## 配置
+## 基本配置
**1. 创建 Telegram Bot 并启用话题模式:**
@@ -76,225 +70,177 @@ TELEGRAM_BOT_TOKEN=your_bot_token_here
ALLOWED_USERS=your_telegram_user_id
```
-**必填项:**
+如果你想手动一次配完整,也可以直接写成这样:
-| 变量 | 说明 |
-|---|---|
-| `TELEGRAM_BOT_TOKEN` | 从 @BotFather 获取的 Bot Token |
-| `ALLOWED_USERS` | 逗号分隔的 Telegram 用户 ID |
+```ini
+TELEGRAM_BOT_TOKEN=your_bot_token_here
+ALLOWED_USERS=123456789
-**可选项:**
+# 默认运行时:claude 或 codex
+CCBOT_RUNTIME=claude
-| 变量 | 默认值 | 说明 |
-|---|---|---|
-| `CCBOT_DIR` | `~/.ccbot` | 配置/状态目录(`.env` 从此目录加载) |
-| `TMUX_SESSION_NAME` | `ccbot` | tmux 会话名称 |
-| `CLAUDE_COMMAND` | `claude` | 新窗口中运行的命令 |
-| `MONITOR_POLL_INTERVAL` | `2.0` | 轮询间隔(秒) |
-| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | 在目录浏览器中显示隐藏(点开头)目录 |
-| `OPENAI_API_KEY` | _(无)_ | OpenAI API 密钥,用于语音消息转录 |
-| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API 基础 URL(用于代理或兼容 API) |
+# 新 tmux 窗口里实际启动的命令
+CLAUDE_COMMAND=claude
+CODEX_COMMAND=codex --no-alt-screen
+
+# 可选
+TMUX_SESSION_NAME=ccbot
+OPENAI_API_KEY=
+OPENAI_BASE_URL=https://api.openai.com/v1
+```
-消息格式化目前固定为 HTML,使用 `chatgpt-md-converter`(`chatgpt_md_converter` 包)。
-不再提供运行时切换到 MarkdownV2 的开关。
+常用可选项:
-> 如果在 VPS 上运行且没有交互终端来批准权限,可以考虑:
-> ```
-> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
-> ```
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `CCBOT_RUNTIME` | `claude` | 新窗口默认启动 `claude` 还是 `codex` |
+| `CCBOT_DIR` | `~/.ccbot` | 配置和状态目录 |
+| `CLAUDE_COMMAND` | `claude` | Claude Code 命令 |
+| `CODEX_COMMAND` | `codex --no-alt-screen` | Codex 命令 |
+| `CODEX_HOME` | `~/.codex` | Codex 配置和会话目录 |
+| `TMUX_SESSION_NAME` | `ccbot` | tmux 会话名 |
+| `MONITOR_POLL_INTERVAL` | `2.0` | 会话监控轮询间隔(秒) |
+| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | 目录选择器里是否显示隐藏目录 |
+| `OPENAI_API_KEY` | _(空)_ | 只在语音转文字时需要 |
+| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 可选的 OpenAI 兼容接口地址 |
+
+说明:
+
+- `ALLOWED_USERS` 支持填写一个或多个 Telegram 用户 ID,用逗号分隔。
+- `ccbot --run claude|codex` 只影响这一次启动,不会改写 `.env`。
+- 如果你是从源码运行,也可以临时放项目内 `.env` 做测试,但长期配置通常还是放在 `~/.ccbot/.env`。
+
+如果你是在没有交互终端的 VPS 上运行,Claude Code 命令可以考虑设置得更少交互一些,例如:
-## Hook 设置(推荐)
+```bash
+CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
+```
-通过 CLI 自动安装:
+## 安装 Hook
+
+执行:
```bash
ccbot hook --install
```
-或手动添加到 `~/.claude/settings.json`:
+如果你想显式把 Claude Code 和 Codex 两边的 hook 都装上:
+
+```bash
+ccbot hook --install --run all
+```
+
+Codex 在 `v0.114.0` 里仍然把 hooks 当实验特性。如果你是在 CCBot 之外手动启动 Codex,请自己加上:
+
+```bash
+codex --enable codex_hooks
+```
+
+如果你不想用 `ccbot hook --install`,也可以手动写配置:
+
+Claude Code 的 `~/.claude/settings.json`:
```json
{
"hooks": {
"SessionStart": [
{
- "hooks": [{ "type": "command", "command": "ccbot hook", "timeout": 5 }]
+ "hooks": [
+ {
+ "type": "command",
+ "command": "ccbot hook",
+ "timeout": 5
+ }
+ ]
}
]
}
}
```
-Hook 会将窗口-会话映射写入 `$CCBOT_DIR/session_map.json`(默认 `~/.ccbot/`),这样 Bot 就能自动追踪每个 tmux 窗口中运行的 Claude 会话 — 即使在 `/clear` 或会话重启后也能保持关联。
-
-## 使用方法
-
-```bash
-# 通过 uv tool / pipx 安装的
-ccbot
+Codex 的 `~/.codex/hooks.json`:
-# 从源码安装的
-uv run ccbot
+```json
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "ccbot hook",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+}
```
-### 命令
-
-**Bot 命令:**
+这样 CCBot 才能持续更新 `session_map.json`,把 tmux 窗口和正确的
+Claude Code / Codex 会话关联起来。
-| 命令 | 说明 |
-|---|---|
-| `/start` | 显示欢迎消息 |
-| `/history` | 当前话题的消息历史 |
-| `/screenshot` | 截取终端屏幕 |
-| `/esc` | 发送 Escape 键中断 Claude |
-
-**Claude Code 命令(通过 tmux 转发):**
-
-| 命令 | 说明 |
-|---|---|
-| `/clear` | 清除对话历史 |
-| `/compact` | 压缩对话上下文 |
-| `/cost` | 显示 Token/费用统计 |
-| `/help` | 显示 Claude Code 帮助 |
-| `/memory` | 编辑 CLAUDE.md |
-
-其他未识别的 `/command` 也会原样转发给 Claude Code(如 `/review`、`/doctor`、`/init`)。
-
-### 话题工作流
-
-**1 话题 = 1 窗口 = 1 会话。** Bot 在 Telegram 论坛(话题)模式下运行。
-
-**创建新会话:**
-
-1. 在 Telegram 群组中创建新话题
-2. 在话题中发送任意消息
-3. 弹出目录浏览器 — 选择项目目录
-4. 如果该目录下已有 Claude 会话,会弹出会话选择器 — 选择恢复已有会话或创建新会话
-5. 自动创建 tmux 窗口,启动 `claude`(恢复时使用 `--resume`),并转发待处理的消息
-
-**发送消息:**
-
-话题绑定会话后,直接在话题中发送文字或语音消息即可 — 文字会通过 tmux 按键转发给 Claude Code,语音消息会自动转录为文字后转发。
-
-**关闭会话:**
-
-在 Telegram 中关闭(或删除)话题,关联的 tmux 窗口会自动终止,绑定也会被移除。
-
-### 消息历史
-
-使用内联按钮导航:
+## 启动 Bot
+```bash
+ccbot
```
-📋 [项目名称] Messages (42 total)
-
-───── 14:32 ─────
-👤 修复登录 bug
+如果你是从源码运行:
-───── 14:33 ─────
+```bash
+uv run ccbot
+```
-我来排查这个登录 bug...
+如果你只想让这一次进程使用 Codex:
-[◀ Older] [2/9] [Newer ▶]
+```bash
+ccbot --run codex
```
-### 通知
+## 日常使用
-监控器每 2 秒轮询会话 JSONL 文件,并发送以下通知:
-- **助手回复** — Claude 的文字回复
-- **思考过程** — 以可展开引用块显示
-- **工具调用/结果** — 带统计摘要(如 "Read 42 lines"、"Found 5 matches")
-- **本地命令输出** — 命令的标准输出(如 `git status`),前缀为 `❯ command_name`
+1. 在 Telegram 群里创建一个新话题
+2. 在这个话题里发送任意消息
+3. 选择项目目录
+4. 选择恢复旧会话或新开会话
+5. 之后继续在这个话题里聊天
-通知发送到绑定了该会话窗口的话题中。
+文字和语音都会转发到绑定的 Claude Code 或 Codex 会话。关闭话题时,
+对应的 tmux 窗口也会一起关闭。
-格式说明:
-- Telegram 消息使用 `HTML` parse mode
-- 通过 `chatgpt-md-converter` 做 Markdown→HTML 转换与 HTML 标签感知拆分,保证长代码块拆分稳定
+## 常用命令
-## 在 tmux 中运行 Claude Code
+| 命令 | 说明 |
+| --- | --- |
+| `/start` | 显示欢迎消息 |
+| `/history` | 查看当前话题的消息历史 |
+| `/screenshot` | 截图当前终端 |
+| `/esc` | 发送 Escape 中断当前会话 |
+| `/unbind` | 解绑当前话题,但保留 tmux 窗口继续运行 |
+| `/kill` | 结束当前话题对应的会话 |
-### 方式一:通过 Telegram 创建(推荐)
+大多数其他 `/...` 命令也会直接转发给当前运行时。
-1. 在 Telegram 群组中创建新话题
-2. 发送任意消息
-3. 从浏览器中选择项目目录
+- Claude Code 菜单里会有 `/usage`、`/help`、`/memory`、`/model` 等命令。
+- Codex 菜单里会有 `/status`、`/plan` 等命令。
+- 菜单里没显示的命令仍然可以手动输入,例如 `/clear`、`/compact`、`/review`、`/model`。
-### 方式二:手动创建
+## 手动使用 tmux
+
+如果你想自己先开窗口:
```bash
tmux attach -t ccbot
tmux new-window -n myproject -c ~/Code/myproject
-# 在新窗口中启动 Claude Code
claude
```
-窗口必须在 `ccbot` tmux 会话中(可通过 `TMUX_SESSION_NAME` 配置)。Claude 启动时 Hook 会自动将其注册到 `session_map.json`。
-
-## 架构概览
-
-```
-┌─────────────┐ ┌─────────────┐ ┌─────────────┐
-│ Topic ID │ ───▶ │ Window ID │ ───▶ │ Session ID │
-│ (Telegram) │ │ (tmux @id) │ │ (Claude) │
-└─────────────┘ └─────────────┘ └─────────────┘
- thread_bindings session_map.json
- (state.json) (由 hook 写入)
-```
-
-**核心设计思路:**
-- **话题为中心** — 每个 Telegram 话题绑定一个 tmux 窗口,话题就是会话列表
-- **窗口 ID 为中心** — 所有内部状态以 tmux 窗口 ID(如 `@0`、`@12`)为键,而非窗口名称。窗口名称仅作为显示名称保留。同一目录可有多个窗口
-- **基于 Hook 的会话追踪** — Claude Code 的 `SessionStart` Hook 写入 `session_map.json`;监控器每次轮询读取它以自动检测会话变化
-- **工具调用配对** — `tool_use_id` 跨轮询周期追踪;工具结果直接编辑原始的工具调用 Telegram 消息
-- **HTML + 降级** — 所有消息通过 `chatgpt-md-converter` 转换为 Telegram HTML,解析失败时降级为纯文本
-- **解析层不截断** — 完整保留内容;发送层按 Telegram 4096 字符限制拆分
-
-## 数据存储
+如果你用的是 Codex:
-| 路径 | 说明 |
-|---|---|
-| `$CCBOT_DIR/state.json` | 话题绑定、窗口状态、显示名称、每用户读取偏移量 |
-| `$CCBOT_DIR/session_map.json` | Hook 生成的 `{tmux_session:window_id: {session_id, cwd, window_name}}` 映射 |
-| `$CCBOT_DIR/monitor_state.json` | 每会话的监控字节偏移量(防止重复通知) |
-| `~/.claude/projects/` | Claude Code 会话数据(只读) |
-
-## 文件结构
-
-```
-src/ccbot/
-├── __init__.py # 包入口
-├── main.py # CLI 调度器(hook 子命令 + bot 启动)
-├── hook.py # Hook 子命令,用于会话追踪(+ --install)
-├── config.py # 环境变量配置
-├── bot.py # Telegram Bot 设置、命令处理、话题路由
-├── session.py # 会话管理、状态持久化、消息历史
-├── session_monitor.py # JSONL 文件监控(轮询 + 变更检测)
-├── monitor_state.py # 监控状态持久化(字节偏移量)
-├── transcript_parser.py # Claude Code JSONL 对话记录解析
-├── terminal_parser.py # 终端面板解析(交互式 UI + 状态行)
-├── html_converter.py # Markdown → Telegram HTML 转换 + HTML 感知拆分
-├── screenshot.py # 终端文字 → PNG 图片(支持 ANSI 颜色)
-├── transcribe.py # 通过 OpenAI API 进行语音转文字
-├── utils.py # 通用工具(原子 JSON 写入、JSONL 辅助函数)
-├── tmux_manager.py # tmux 窗口管理(列出、创建、发送按键、终止)
-├── fonts/ # 截图渲染用字体
-└── handlers/
- ├── __init__.py # Handler 模块导出
- ├── callback_data.py # 回调数据常量(CB_* 前缀)
- ├── directory_browser.py # 目录浏览器内联键盘 UI
- ├── history.py # 消息历史分页
- ├── interactive_ui.py # 交互式 UI 处理(AskUser、ExitPlan、权限)
- ├── message_queue.py # 每用户消息队列 + worker(合并、限流)
- ├── message_sender.py # safe_reply / safe_edit / safe_send 辅助函数
- ├── response_builder.py # 响应消息构建(格式化 tool_use、思考等)
- └── status_polling.py # 终端状态行轮询
+```bash
+tmux attach -t ccbot
+tmux new-window -n myproject -c ~/Code/myproject
+codex --no-alt-screen --enable codex_hooks --enable default_mode_request_user_input
```
-
-## 贡献者
-
-感谢所有贡献者!我们鼓励使用 Claude Code 协同参与项目贡献。
-
-
-
-
diff --git a/pyproject.toml b/pyproject.toml
index f02ba25c..c43dd24b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "ccbot"
version = "0.1.0"
-description = "Telegram Bot for monitoring Claude Code sessions"
+description = "Telegram Bot for monitoring runtime sessions in tmux"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
@@ -11,7 +11,7 @@ dependencies = [
"libtmux>=0.37.0",
"Pillow>=10.0.0",
"aiofiles>=24.0.0",
- "telegramify-markdown>=0.5.0",
+ "telegramify-markdown>=0.5.0,<1.0.0",
]
[project.scripts]
diff --git a/src/ccbot/__init__.py b/src/ccbot/__init__.py
index d67e3d57..7dd1714e 100644
--- a/src/ccbot/__init__.py
+++ b/src/ccbot/__init__.py
@@ -1,4 +1,4 @@
-"""CCBot - Telegram Bot for managing Claude Code sessions via tmux.
+"""CCBot - Telegram Bot for managing Claude Code or Codex sessions via tmux.
Package entry point. Exports the version string only; all functional
modules are imported lazily by main.py to keep startup fast.
diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py
index bf6e8009..9882488c 100644
--- a/src/ccbot/bot.py
+++ b/src/ccbot/bot.py
@@ -68,6 +68,10 @@
CB_ASK_SPACE,
CB_ASK_TAB,
CB_ASK_UP,
+ CB_CODEX_PROMPT_CANCEL,
+ CB_CODEX_PROMPT_OPTION,
+ CB_CODEX_PROMPT_OTHER,
+ CB_CODEX_PROMPT_REFRESH,
CB_DIR_CANCEL,
CB_DIR_CONFIRM,
CB_DIR_PAGE,
@@ -105,12 +109,19 @@
from .handlers.history import send_history
from .handlers.interactive_ui import (
INTERACTIVE_TOOL_NAMES,
+ advance_codex_prompt_with_option,
+ arm_codex_prompt_notes_text,
clear_interactive_mode,
clear_interactive_msg,
+ get_codex_prompt_state,
get_interactive_msg_id,
get_interactive_window,
+ handle_codex_prompt,
handle_interactive_ui,
+ has_codex_prompt,
+ is_waiting_for_codex_notes_text,
set_interactive_mode,
+ submit_codex_prompt_notes_text,
)
from .handlers.message_queue import (
clear_status_msg_info,
@@ -129,6 +140,7 @@
from .markdown_v2 import convert_markdown
from .handlers.response_builder import build_response_parts
from .handlers.status_polling import status_poll_loop
+from .runtimes import RUNTIME_CODEX
from .screenshot import text_to_image
from .session import session_manager
from .session_monitor import NewMessage, SessionMonitor
@@ -139,6 +151,7 @@
from .utils import ccbot_dir
logger = logging.getLogger(__name__)
+_PROACTIVE_INTERACTIVE_COMMANDS = frozenset({"/model"})
# Session monitor instance
session_monitor: SessionMonitor | None = None
@@ -146,21 +159,82 @@
# Status polling task
_status_poll_task: asyncio.Task | None = None
-# Claude Code commands shown in bot menu (forwarded via tmux)
-CC_COMMANDS: dict[str, str] = {
- "clear": "↗ Clear conversation history",
- "compact": "↗ Compact conversation context",
- "cost": "↗ Show token/cost usage",
- "help": "↗ Show Claude Code help",
- "memory": "↗ Edit CLAUDE.md",
- "model": "↗ Switch AI model",
-}
-
-
def is_user_allowed(user_id: int | None) -> bool:
return user_id is not None and config.is_user_allowed(user_id)
+def _runtime_display_name() -> str:
+ """Return the user-facing runtime name."""
+ if config.runtime == RUNTIME_CODEX:
+ return "Codex"
+ return "Claude Code"
+
+
+def _runtime_monitor_title() -> str:
+ """Return the Telegram welcome title for the active runtime."""
+ if config.runtime == RUNTIME_CODEX:
+ return "Codex Monitor"
+ return "Claude Code Monitor"
+
+
+def _runtime_forwarded_bot_commands() -> dict[str, str]:
+ """Return menu commands forwarded to the active runtime."""
+ if config.runtime == RUNTIME_CODEX:
+ # Keep the visible Codex menu aligned with the public slash command docs:
+ # https://developers.openai.com/codex/cli/slash-commands/
+ return {
+ "clear": "↗ Clear conversation history",
+ "compact": "↗ Compact conversation context",
+ "plan": "↗ Use plan mode for the next task",
+ }
+
+ return {
+ "clear": "↗ Clear conversation history",
+ "compact": "↗ Compact conversation context",
+ "cost": "↗ Show token/cost usage",
+ "help": "↗ Show Claude Code help",
+ "memory": "↗ Edit CLAUDE.md",
+ "model": "↗ Switch Claude model",
+ }
+
+
+def _runtime_status_slash_command() -> str:
+ """Return the runtime-native slash command for status/usage info."""
+ if config.runtime == RUNTIME_CODEX:
+ return "/status"
+ return "/usage"
+
+
+def _runtime_status_bot_command() -> tuple[str, str]:
+ """Return the Telegram bot command name/description shown in the menu."""
+ if config.runtime == RUNTIME_CODEX:
+ return ("status", "Show Codex status")
+ return ("usage", "Show Claude Code usage remaining")
+
+
+def _runtime_escape_description() -> str:
+ """Return the menu description for the /esc command."""
+ return f"Send Escape to interrupt {_runtime_display_name()}"
+
+
+def _build_bot_commands() -> list[BotCommand]:
+ """Build the Telegram command menu for the active runtime."""
+ commands = [
+ BotCommand("start", "Show welcome message"),
+ BotCommand("history", "Message history for this topic"),
+ BotCommand("screenshot", "Terminal screenshot with control keys"),
+ BotCommand("esc", _runtime_escape_description()),
+ BotCommand("kill", "Kill session for this topic"),
+ BotCommand("unbind", "Unbind topic from session (keeps window running)"),
+ BotCommand(*_runtime_status_bot_command()),
+ ]
+ commands.extend(
+ BotCommand(cmd_name, desc)
+ for cmd_name, desc in _runtime_forwarded_bot_commands().items()
+ )
+ return commands
+
+
def _get_thread_id(update: Update) -> int | None:
"""Extract thread_id from an update, returning None if not in a named topic."""
msg = update.message or (
@@ -189,7 +263,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
if update.message:
await safe_reply(
update.message,
- "🤖 *Claude Code Monitor*\n\n"
+ f"🤖 *{_runtime_monitor_title()}*\n\n"
"Each topic is a session. Create a new topic to start.",
)
@@ -248,7 +322,7 @@ async def screenshot_command(
async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Unbind this topic from its Claude session without killing the window."""
+ """Unbind this topic from its runtime session without killing the window."""
user = update.effective_user
if not user or not is_user_allowed(user.id):
return
@@ -272,13 +346,64 @@ async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await safe_reply(
update.message,
f"✅ Topic unbound from window '{display}'.\n"
- "The Claude session is still running in tmux.\n"
+ f"The {_runtime_display_name()} session is still running in tmux.\n"
"Send a message to bind to a new session.",
)
+async def kill_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Kill the tmux window bound to this topic and clear local state."""
+ user = update.effective_user
+ if not user or not is_user_allowed(user.id):
+ return
+ if not update.message:
+ return
+
+ thread_id = _get_thread_id(update)
+ if thread_id is None:
+ await safe_reply(update.message, "❌ This command only works in a topic.")
+ return
+
+ wid = session_manager.get_window_for_thread(user.id, thread_id)
+ if not wid:
+ await safe_reply(update.message, "❌ No session bound to this topic.")
+ return
+
+ display = session_manager.get_display_name(wid)
+ w = await tmux_manager.find_window_by_id(wid)
+ if config.runtime == RUNTIME_CODEX and w:
+ sent_exit, detail = await session_manager.send_to_window(wid, "/exit")
+ if not sent_exit:
+ logger.warning(
+ "Codex /kill: failed to send /exit to %s (%s): %s",
+ display,
+ wid,
+ detail,
+ )
+ else:
+ await asyncio.sleep(1.0)
+ w = await tmux_manager.find_window_by_id(wid)
+
+ if w:
+ killed = await tmux_manager.kill_window(w.window_id)
+ if not killed:
+ await safe_reply(
+ update.message,
+ f"❌ Failed to kill window '{display}'.",
+ )
+ return
+
+ session_manager.unbind_thread(user.id, thread_id)
+ session_manager.remove_window(wid)
+ await clear_topic_state(user.id, thread_id, context.bot, context.user_data)
+ await safe_reply(
+ update.message,
+ f"✅ Killed session '{display}'.\nSend a message to start a new session.",
+ )
+
+
async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Send Escape key to interrupt Claude."""
+ """Send Escape key to interrupt the active runtime."""
user = update.effective_user
if not user or not is_user_allowed(user.id):
return
@@ -303,7 +428,7 @@ async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Fetch Claude Code usage stats from TUI and send to Telegram."""
+ """Fetch runtime status/usage info from the active tmux session."""
user = update.effective_user
if not user or not is_user_allowed(user.id):
return
@@ -321,8 +446,10 @@ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await safe_reply(update.message, f"Window '{wid}' no longer exists.")
return
- # Send /usage command to Claude Code TUI
- await tmux_manager.send_keys(w.window_id, "/usage")
+ runtime_command = _runtime_status_slash_command()
+
+ # Send the runtime-native command to the TUI.
+ await tmux_manager.send_keys(w.window_id, runtime_command)
# Wait for the modal to render
await asyncio.sleep(2.0)
# Capture the pane content
@@ -331,7 +458,7 @@ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await tmux_manager.send_keys(w.window_id, "Escape", enter=False, literal=False)
if not pane_text:
- await safe_reply(update.message, "Failed to capture usage info.")
+ await safe_reply(update.message, "Failed to capture session status.")
return
# Try to parse structured usage info
@@ -530,10 +657,26 @@ async def forward_command_handler(
logger.info("Clearing session for window %s after /clear", display)
session_manager.clear_window_session(wid)
- # Interactive commands (e.g. /model) render a terminal-based UI
- # with no JSONL tool_use entry. The status poller already detects
- # interactive UIs every 1s (status_polling.py), so no
- # proactive detection needed here — the poller handles it.
+ normalized_command = cc_slash.strip().lower()
+ if normalized_command in _PROACTIVE_INTERACTIVE_COMMANDS:
+ for _ in range(20):
+ await asyncio.sleep(0.1)
+ handled = await handle_interactive_ui(
+ context.bot, user.id, wid, thread_id
+ )
+ if handled:
+ break
+ else:
+ pane_text = await tmux_manager.capture_pane(w.window_id)
+ if (
+ pane_text
+ and "Model selection is disabled until startup completes."
+ in pane_text
+ ):
+ await safe_reply(
+ update.message,
+ "Codex is still starting up. Wait for startup to finish, then run /model again.",
+ )
else:
await safe_reply(update.message, f"❌ {message}")
@@ -551,7 +694,8 @@ async def unsupported_content_handler(
logger.debug("Unsupported content from user %d", user.id)
await safe_reply(
update.message,
- "⚠ Only text, photo, and voice messages are supported. Stickers, video, and other media cannot be forwarded to Claude Code.",
+ "⚠ Only text, photo, and voice messages are supported. "
+ f"Stickers, video, and other media cannot be forwarded to {_runtime_display_name()}.",
)
@@ -584,6 +728,14 @@ async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
)
return
+ if has_codex_prompt(user.id, thread_id):
+ await safe_reply(
+ update.message,
+ "Please answer the pending Codex question with the buttons above. "
+ "If you need free text, tap Add notes and then send text.",
+ )
+ return
+
wid = session_manager.get_window_for_thread(user.id, thread_id)
if wid is None:
await safe_reply(
@@ -612,7 +764,7 @@ async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
file_path = _IMAGES_DIR / filename
await tg_file.download_to_drive(file_path)
- # Build the message to send to Claude Code
+ # Build the message to send to the active runtime
caption = update.message.caption or ""
if caption:
text_to_send = f"{caption}\n\n(image attached: {file_path})"
@@ -628,7 +780,7 @@ async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
return
# Confirm to user
- await safe_reply(update.message, "📷 Image sent to Claude Code.")
+ await safe_reply(update.message, f"📷 Image sent to {_runtime_display_name()}.")
async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -662,6 +814,14 @@ async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
)
return
+ if has_codex_prompt(user.id, thread_id):
+ await safe_reply(
+ update.message,
+ "Please answer the pending Codex question with the buttons above. "
+ "If you need free text, tap Add notes and then send text.",
+ )
+ return
+
wid = session_manager.get_window_for_thread(user.id, thread_id)
if wid is None:
await safe_reply(
@@ -877,6 +1037,25 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
)
return
+ if has_codex_prompt(user.id, thread_id):
+ if is_waiting_for_codex_notes_text(user.id, thread_id):
+ success, message = await submit_codex_prompt_notes_text(
+ context.bot,
+ user.id,
+ thread_id,
+ text,
+ )
+ if not success:
+ await safe_reply(update.message, f"❌ {message}")
+ return
+
+ await safe_reply(
+ update.message,
+ "Please answer the pending Codex question with the buttons above. "
+ "If you need free text, tap Add notes first.",
+ )
+ return
+
wid = session_manager.get_window_for_thread(user.id, thread_id)
if wid is None:
# Unbound topic — check for unbound windows first
@@ -1020,24 +1199,24 @@ async def _create_and_bind_window(
pending_thread_id,
resume_session_id,
)
- # Wait for Claude Code's SessionStart hook to register in session_map.
+ # Wait for the runtime to register its session in session_map.
# Resume sessions take longer to start (loading session state), so use
# a longer timeout to avoid silently dropping messages.
- hook_timeout = 15.0 if resume_session_id else 5.0
- hook_ok = await session_manager.wait_for_session_map_entry(
- created_wid, timeout=hook_timeout
+ registration_timeout = 15.0 if resume_session_id else 5.0
+ session_registered = await session_manager.wait_for_session_map_entry(
+ created_wid, timeout=registration_timeout
)
- # --resume creates a new session_id in the hook, but messages continue
+ # --resume may create a new runtime session ID during startup, but messages continue
# writing to the resumed session's JSONL file. Override window_state to
# track the original session_id so the monitor can route messages back.
if resume_session_id:
ws = session_manager.get_window_state(created_wid)
- if not hook_ok:
- # Hook timed out — manually populate window_state so the
+ if not session_registered:
+ # Session registration timed out — manually populate window_state so the
# monitor can still route messages back to this topic.
logger.warning(
- "Hook timed out for resume window %s, "
+ "Session registration timed out for resume window %s, "
"manually setting session_id=%s cwd=%s",
created_wid,
resume_session_id,
@@ -1046,6 +1225,7 @@ async def _create_and_bind_window(
ws.session_id = resume_session_id
ws.cwd = str(selected_path)
ws.window_name = created_wname
+ ws.runtime = config.runtime
session_manager._save_state()
elif ws.session_id != resume_session_id:
logger.info(
@@ -1533,6 +1713,77 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -
await safe_edit(query, "Cancelled")
await query.answer("Cancelled")
+ elif data.startswith(CB_CODEX_PROMPT_OPTION):
+ rest = data[len(CB_CODEX_PROMPT_OPTION) :]
+ try:
+ question_idx_str, option_idx_str, window_id = rest.split(":", 2)
+ question_index = int(question_idx_str)
+ option_index = int(option_idx_str)
+ except (ValueError, TypeError):
+ await query.answer("Invalid data", show_alert=True)
+ return
+
+ thread_id = _get_thread_id(update)
+ success, message = await advance_codex_prompt_with_option(
+ context.bot,
+ user.id,
+ window_id,
+ thread_id,
+ question_index,
+ option_index,
+ )
+ await query.answer(message, show_alert=not success)
+
+ elif data.startswith(CB_CODEX_PROMPT_OTHER):
+ rest = data[len(CB_CODEX_PROMPT_OTHER) :]
+ try:
+ question_idx_str, window_id = rest.split(":", 1)
+ question_index = int(question_idx_str)
+ except (ValueError, TypeError):
+ await query.answer("Invalid data", show_alert=True)
+ return
+
+ thread_id = _get_thread_id(update)
+ success, message = await arm_codex_prompt_notes_text(
+ context.bot,
+ user.id,
+ window_id,
+ thread_id,
+ question_index,
+ )
+ await query.answer(message, show_alert=not success)
+
+ elif data.startswith(CB_CODEX_PROMPT_CANCEL):
+ window_id = data[len(CB_CODEX_PROMPT_CANCEL) :]
+ thread_id = _get_thread_id(update)
+ w = await tmux_manager.find_window_by_id(window_id)
+ if w:
+ await tmux_manager.send_keys(
+ w.window_id, "Escape", enter=False, literal=False
+ )
+ await clear_interactive_msg(user.id, context.bot, thread_id)
+ await query.answer("Cancelled")
+
+ elif data.startswith(CB_CODEX_PROMPT_REFRESH):
+ window_id = data[len(CB_CODEX_PROMPT_REFRESH) :]
+ thread_id = _get_thread_id(update)
+ state = get_codex_prompt_state(user.id, thread_id)
+ if not state or state.window_id != window_id:
+ await query.answer("Prompt is no longer active", show_alert=True)
+ return
+ handled = await handle_codex_prompt(
+ context.bot,
+ user.id,
+ window_id,
+ state.prompt,
+ state.tool_use_id,
+ thread_id,
+ )
+ await query.answer(
+ "Refreshed" if handled else "Refresh failed",
+ show_alert=not handled,
+ )
+
# Screenshot: Refresh
elif data.startswith(CB_SCREENSHOT_REFRESH):
window_id = data[len(CB_SCREENSHOT_REFRESH) :]
@@ -1736,6 +1987,35 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None:
return
for user_id, wid, thread_id in active_users:
+ if (
+ msg.interactive_prompt is not None
+ and msg.tool_name == "request_user_input"
+ and msg.content_type == "tool_use"
+ and msg.tool_use_id
+ ):
+ queue = get_message_queue(user_id)
+ if queue:
+ await queue.join()
+ handled = await handle_codex_prompt(
+ bot,
+ user_id,
+ wid,
+ msg.interactive_prompt,
+ msg.tool_use_id,
+ thread_id,
+ )
+ if handled:
+ session = await session_manager.resolve_session_for_window(wid)
+ if session and session.file_path:
+ try:
+ file_size = Path(session.file_path).stat().st_size
+ session_manager.update_user_window_offset(
+ user_id, wid, file_size
+ )
+ except OSError:
+ pass
+ continue
+
# Handle interactive tools specially - capture terminal and send UI
if msg.tool_name in INTERACTIVE_TOOL_NAMES and msg.content_type == "tool_use":
# Mark interactive mode BEFORE sleeping so polling skips this window
@@ -1809,20 +2089,7 @@ async def post_init(application: Application) -> None:
await application.bot.delete_my_commands()
- bot_commands = [
- BotCommand("start", "Show welcome message"),
- BotCommand("history", "Message history for this topic"),
- BotCommand("screenshot", "Terminal screenshot with control keys"),
- BotCommand("esc", "Send Escape to interrupt Claude"),
- BotCommand("kill", "Kill session and delete topic"),
- BotCommand("unbind", "Unbind topic from session (keeps window running)"),
- BotCommand("usage", "Show Claude Code usage remaining"),
- ]
- # Add Claude Code slash commands
- for cmd_name, desc in CC_COMMANDS.items():
- bot_commands.append(BotCommand(cmd_name, desc))
-
- await application.bot.set_my_commands(bot_commands)
+ await application.bot.set_my_commands(_build_bot_commands())
# Re-resolve stale window IDs from persisted state against live tmux windows
await session_manager.resolve_stale_ids()
@@ -1890,8 +2157,11 @@ def create_bot() -> Application:
application.add_handler(CommandHandler("history", history_command))
application.add_handler(CommandHandler("screenshot", screenshot_command))
application.add_handler(CommandHandler("esc", esc_command))
+ application.add_handler(CommandHandler("kill", kill_command))
application.add_handler(CommandHandler("unbind", unbind_command))
application.add_handler(CommandHandler("usage", usage_command))
+ if config.runtime == RUNTIME_CODEX:
+ application.add_handler(CommandHandler("status", usage_command))
application.add_handler(CallbackQueryHandler(callback_handler))
# Topic closed event — auto-kill associated window
application.add_handler(
diff --git a/src/ccbot/config.py b/src/ccbot/config.py
index ca3d6744..aa7d6ee0 100644
--- a/src/ccbot/config.py
+++ b/src/ccbot/config.py
@@ -1,7 +1,7 @@
"""Application configuration — reads env vars and exposes a singleton.
-Loads TELEGRAM_BOT_TOKEN, ALLOWED_USERS, tmux/Claude paths, and
-monitoring intervals from environment variables (with .env support).
+Loads TELEGRAM_BOT_TOKEN, ALLOWED_USERS, tmux/runtime paths, and monitoring
+intervals from environment variables (with .env support).
.env loading priority: local .env (cwd) > $CCBOT_DIR/.env (default ~/.ccbot).
The module-level `config` instance is imported by nearly every other module.
@@ -10,16 +10,58 @@
import logging
import os
+import shlex
from pathlib import Path
from dotenv import load_dotenv
+from .runtimes import RUNTIME_CLAUDE, SUPPORTED_RUNTIMES
from .utils import ccbot_dir
logger = logging.getLogger(__name__)
# Env vars that must not leak to child processes (e.g. Claude Code via tmux)
SENSITIVE_ENV_VARS = {"TELEGRAM_BOT_TOKEN", "ALLOWED_USERS", "OPENAI_API_KEY"}
+_REQUIRED_CODEX_FEATURES = ("codex_hooks", "default_mode_request_user_input")
+
+
+def _ensure_codex_features_enabled(command: str) -> str:
+ """Append required Codex feature flags when they are not already enabled."""
+ try:
+ parts = shlex.split(command)
+ except ValueError:
+ missing = [
+ feature for feature in _REQUIRED_CODEX_FEATURES if feature not in command
+ ]
+ if not missing:
+ return command
+ suffix = " ".join(f"--enable {feature}" for feature in missing)
+ return f"{command} {suffix}".strip()
+
+ enabled_features: set[str] = set()
+ for idx, token in enumerate(parts):
+ if token == "--enable" and idx + 1 < len(parts):
+ enabled_features.add(parts[idx + 1])
+ continue
+ if token.startswith("--enable="):
+ enabled_features.add(token.split("=", 1)[1])
+ continue
+ if token in {"-c", "--config"} and idx + 1 < len(parts):
+ value = parts[idx + 1].replace(" ", "")
+ if not value.startswith("features.") or not value.endswith("=true"):
+ continue
+ feature_name = value[len("features.") : -len("=true")]
+ if feature_name:
+ enabled_features.add(feature_name)
+
+ missing = [
+ feature for feature in _REQUIRED_CODEX_FEATURES if feature not in enabled_features
+ ]
+ if not missing:
+ return command
+
+ suffix = " ".join(f"--enable {feature}" for feature in missing)
+ return f"{command} {suffix}".strip()
class Config:
@@ -61,8 +103,18 @@ def __init__(self) -> None:
self.tmux_session_name = os.getenv("TMUX_SESSION_NAME", "ccbot")
self.tmux_main_window_name = "__main__"
- # Claude command to run in new windows
+ self.runtime = os.getenv("CCBOT_RUNTIME", RUNTIME_CLAUDE)
+ if self.runtime not in SUPPORTED_RUNTIMES:
+ raise ValueError(
+ "CCBOT_RUNTIME must be one of "
+ f"{', '.join(sorted(SUPPORTED_RUNTIMES))}"
+ )
+
+ # Commands used to start a runtime in new windows
self.claude_command = os.getenv("CLAUDE_COMMAND", "claude")
+ self.codex_command = _ensure_codex_features_enabled(
+ os.getenv("CODEX_COMMAND", "codex --no-alt-screen")
+ )
# All state files live under config_dir
self.state_file = self.config_dir / "state.json"
@@ -82,6 +134,16 @@ def __init__(self) -> None:
else:
self.claude_projects_path = Path.home() / ".claude" / "projects"
+ codex_home = os.getenv("CODEX_HOME", "").strip()
+ self.codex_home = (
+ Path(codex_home).expanduser()
+ if codex_home
+ else Path.home() / ".codex"
+ )
+ self.codex_sessions_path = self.codex_home / "sessions"
+ self.codex_session_index_file = self.codex_home / "session_index.jsonl"
+ self.codex_hooks_file = self.codex_home / "hooks.json"
+
self.monitor_poll_interval = float(os.getenv("MONITOR_POLL_INTERVAL", "2.0"))
# Display user messages in history and real-time notifications
@@ -106,12 +168,15 @@ def __init__(self) -> None:
logger.debug(
"Config initialized: dir=%s, token=%s..., allowed_users=%d, "
- "tmux_session=%s, claude_projects_path=%s",
+ "tmux_session=%s, runtime=%s, claude_projects_path=%s, "
+ "codex_home=%s",
self.config_dir,
self.telegram_bot_token[:8],
len(self.allowed_users),
self.tmux_session_name,
+ self.runtime,
self.claude_projects_path,
+ self.codex_home,
)
def is_user_allowed(self, user_id: int) -> bool:
diff --git a/src/ccbot/handlers/callback_data.py b/src/ccbot/handlers/callback_data.py
index e4846aff..98f7949d 100644
--- a/src/ccbot/handlers/callback_data.py
+++ b/src/ccbot/handlers/callback_data.py
@@ -42,6 +42,12 @@
CB_ASK_TAB = "aq:tab:" # aq:tab:
CB_ASK_REFRESH = "aq:ref:" # aq:ref:
+# Codex request_user_input prompts
+CB_CODEX_PROMPT_OPTION = "cq:o:" # cq:o:::
+CB_CODEX_PROMPT_OTHER = "cq:t:" # cq:t:: (add notes)
+CB_CODEX_PROMPT_CANCEL = "cq:c:" # cq:c:
+CB_CODEX_PROMPT_REFRESH = "cq:r:" # cq:r:
+
# Session picker (resume existing session)
CB_SESSION_SELECT = "rs:sel:" # rs:sel:
CB_SESSION_NEW = "rs:new" # start a new session
diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py
index 174e3a9e..53f1cc86 100644
--- a/src/ccbot/handlers/interactive_ui.py
+++ b/src/ccbot/handlers/interactive_ui.py
@@ -1,26 +1,24 @@
-"""Interactive UI handling for Claude Code prompts.
+"""Interactive UI handling for Claude Code and Codex prompts.
-Handles interactive terminal UIs displayed by Claude Code:
- - AskUserQuestion: Multi-choice question prompts
- - ExitPlanMode: Plan mode exit confirmation
- - Permission Prompt: Tool permission requests
- - RestoreCheckpoint: Checkpoint restoration selection
-
-Provides:
- - Keyboard navigation (up/down/left/right/enter/esc)
- - Terminal capture and display
- - Interactive mode tracking per user and thread
+Supports two interaction styles:
+ - Terminal-driven UIs captured from tmux (Claude AskUserQuestion, ExitPlanMode,
+ permission prompts, settings, checkpoint restore)
+ - Codex `request_user_input` prompts rendered natively in Telegram
State dicts are keyed by (user_id, thread_id_or_0) for Telegram topic support.
"""
+import asyncio
import logging
+import re
+from dataclasses import dataclass, field
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup
from ..session import session_manager
from ..terminal_parser import extract_interactive_content, is_interactive_ui
from ..tmux_manager import tmux_manager
+from ..transcript_parser import CodexPromptPayload, CodexPromptQuestion
from .callback_data import (
CB_ASK_DOWN,
CB_ASK_ENTER,
@@ -31,13 +29,20 @@
CB_ASK_SPACE,
CB_ASK_TAB,
CB_ASK_UP,
+ CB_CODEX_PROMPT_CANCEL,
+ CB_CODEX_PROMPT_OPTION,
+ CB_CODEX_PROMPT_OTHER,
+ CB_CODEX_PROMPT_REFRESH,
)
from .message_sender import NO_LINK_PREVIEW
logger = logging.getLogger(__name__)
+_RE_CODEX_PROMPT_FOCUS = re.compile(r"^\s*›\s*(\d+)\.")
# Tool names that trigger interactive UI via JSONL (terminal capture + inline keyboard)
-INTERACTIVE_TOOL_NAMES = frozenset({"AskUserQuestion", "ExitPlanMode"})
+INTERACTIVE_TOOL_NAMES = frozenset(
+ {"AskUserQuestion", "ExitPlanMode", "request_user_input"}
+)
# Track interactive UI message IDs: (user_id, thread_id_or_0) -> message_id
_interactive_msgs: dict[tuple[int, int], int] = {}
@@ -45,6 +50,25 @@
# Track interactive mode: (user_id, thread_id_or_0) -> window_id
_interactive_mode: dict[tuple[int, int], str] = {}
+# Track the last terminal UI text sent for a user/thread to avoid duplicate edits
+_interactive_text_cache: dict[tuple[int, int], str] = {}
+
+
+@dataclass
+class CodexPromptState:
+ """Telegram-side state for a pending Codex request_user_input prompt."""
+
+ window_id: str
+ tool_use_id: str
+ prompt: CodexPromptPayload
+ question_index: int = 0
+ answers: dict[str, str] = field(default_factory=dict)
+ notes: dict[str, str] = field(default_factory=dict)
+ awaiting_notes_text: bool = False
+
+
+_codex_prompt_states: dict[tuple[int, int], CodexPromptState] = {}
+
def get_interactive_window(user_id: int, thread_id: int | None = None) -> str | None:
"""Get the window_id for user's interactive mode."""
@@ -77,6 +101,381 @@ def get_interactive_msg_id(user_id: int, thread_id: int | None = None) -> int |
return _interactive_msgs.get((user_id, thread_id or 0))
+def get_codex_prompt_state(
+ user_id: int, thread_id: int | None = None
+) -> CodexPromptState | None:
+ """Get the pending Codex request_user_input state for a user/thread."""
+ return _codex_prompt_states.get((user_id, thread_id or 0))
+
+
+def has_codex_prompt(user_id: int, thread_id: int | None = None) -> bool:
+ """Check whether a Codex prompt is currently active."""
+ return get_codex_prompt_state(user_id, thread_id) is not None
+
+
+def is_waiting_for_codex_notes_text(
+ user_id: int,
+ thread_id: int | None = None,
+) -> bool:
+ """Check whether the next user text should be saved as Codex notes."""
+ state = get_codex_prompt_state(user_id, thread_id)
+ return state.awaiting_notes_text if state else False
+
+
+def _get_state_key(user_id: int, thread_id: int | None) -> tuple[int, int]:
+ return (user_id, thread_id or 0)
+
+
+def _current_codex_question(state: CodexPromptState) -> CodexPromptQuestion:
+ return state.prompt.questions[state.question_index]
+
+
+def _render_codex_prompt_text(state: CodexPromptState) -> str:
+ """Render the current Codex prompt question as plain text."""
+ question = _current_codex_question(state)
+ total = len(state.prompt.questions)
+ lines = [f"Question {state.question_index + 1}/{total}"]
+ if question.header:
+ lines.extend(["", question.header])
+ lines.extend(["", question.question, ""])
+ for index, option in enumerate(question.options, start=1):
+ lines.append(f"{index}. {option.label}")
+ if option.description:
+ lines.append(f" {option.description}")
+
+ lines.append(f"{len(question.options) + 1}. None of the above")
+ lines.append(" Optionally, add details in notes.")
+
+ note_text = state.notes.get(question.question_id, "").strip()
+ if note_text:
+ lines.extend(["", f"Notes: {note_text}"])
+
+ if state.awaiting_notes_text:
+ lines.extend(
+ [
+ "",
+ "Send your next text message in this topic to add notes for this question.",
+ ]
+ )
+ else:
+ lines.extend(
+ [
+ "",
+ "Tap an option below to submit this question.",
+ "Optional: tap Add notes first if you want to include free text.",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _build_codex_prompt_keyboard(
+ state: CodexPromptState,
+) -> InlineKeyboardMarkup:
+ """Build the inline keyboard for the current Codex prompt question."""
+ question = _current_codex_question(state)
+ rows: list[list[InlineKeyboardButton]] = []
+ for option_index, option in enumerate(question.options):
+ rows.append(
+ [
+ InlineKeyboardButton(
+ option.label,
+ callback_data=(
+ f"{CB_CODEX_PROMPT_OPTION}{state.question_index}:{option_index}:"
+ f"{state.window_id}"
+ )[:64],
+ )
+ ]
+ )
+ rows.append(
+ [
+ InlineKeyboardButton(
+ "None of the above",
+ callback_data=(
+ f"{CB_CODEX_PROMPT_OPTION}{state.question_index}:{len(question.options)}:"
+ f"{state.window_id}"
+ )[:64],
+ )
+ ]
+ )
+ rows.append(
+ [
+ InlineKeyboardButton(
+ "Update notes" if state.notes.get(question.question_id) else "Add notes",
+ callback_data=(
+ f"{CB_CODEX_PROMPT_OTHER}{state.question_index}:{state.window_id}"
+ )[:64],
+ )
+ ]
+ )
+ rows.append(
+ [
+ InlineKeyboardButton(
+ "Cancel",
+ callback_data=f"{CB_CODEX_PROMPT_CANCEL}{state.window_id}"[:64],
+ ),
+ InlineKeyboardButton(
+ "Refresh",
+ callback_data=f"{CB_CODEX_PROMPT_REFRESH}{state.window_id}"[:64],
+ ),
+ ]
+ )
+ return InlineKeyboardMarkup(rows)
+
+
+async def _render_codex_prompt_message(
+ bot: Bot,
+ user_id: int,
+ thread_id: int | None,
+ state: CodexPromptState,
+) -> bool:
+ """Send or update the Telegram message for the current Codex prompt."""
+ ikey = _get_state_key(user_id, thread_id)
+ chat_id = session_manager.resolve_chat_id(user_id, thread_id)
+ text = _render_codex_prompt_text(state)
+ keyboard = _build_codex_prompt_keyboard(state)
+ thread_kwargs: dict[str, int] = {}
+ if thread_id is not None:
+ thread_kwargs["message_thread_id"] = thread_id
+
+ existing_msg_id = _interactive_msgs.get(ikey)
+ if existing_msg_id:
+ try:
+ await bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=existing_msg_id,
+ text=text,
+ reply_markup=keyboard,
+ link_preview_options=NO_LINK_PREVIEW,
+ )
+ _interactive_mode[ikey] = state.window_id
+ return True
+ except Exception:
+ logger.debug(
+ "Edit failed for Codex prompt msg %s, sending new", existing_msg_id
+ )
+ _interactive_msgs.pop(ikey, None)
+
+ try:
+ sent = await bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ reply_markup=keyboard,
+ link_preview_options=NO_LINK_PREVIEW,
+ **thread_kwargs, # type: ignore[arg-type]
+ )
+ except Exception as exc:
+ logger.error("Failed to send Codex prompt UI: %s", exc)
+ return False
+
+ _interactive_msgs[ikey] = sent.message_id
+ _interactive_mode[ikey] = state.window_id
+ return True
+
+
+async def _send_special_key(window_id: str, key: str) -> bool:
+ return await tmux_manager.send_keys(window_id, key, enter=False, literal=False)
+
+
+async def _move_codex_prompt_cursor_to_option(
+ window_id: str,
+ question: CodexPromptQuestion,
+ option_index: int,
+) -> bool:
+ """Move the Codex TUI cursor to a target option within the current question."""
+ pane_text = await tmux_manager.capture_pane(window_id)
+ current_index = _find_codex_prompt_focus_index(pane_text, question)
+ if current_index is None:
+ current_index = 0
+
+ if current_index == option_index:
+ return True
+
+ key = "Down" if option_index > current_index else "Up"
+ steps = abs(option_index - current_index)
+ for _ in range(steps):
+ if not await _send_special_key(window_id, key):
+ return False
+ await asyncio.sleep(0.05)
+ return True
+
+
+def _find_codex_prompt_focus_index(
+ pane_text: str | None,
+ question: CodexPromptQuestion,
+) -> int | None:
+ """Return the currently highlighted Codex option index from pane text."""
+ if not pane_text:
+ return None
+
+ current_question = False
+ for line in pane_text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if question.question in stripped:
+ current_question = True
+ continue
+ if not current_question:
+ continue
+ match = _RE_CODEX_PROMPT_FOCUS.match(line)
+ if match:
+ focus_index = int(match.group(1)) - 1
+ if 0 <= focus_index <= len(question.options):
+ return focus_index
+ if stripped.startswith("tab to add notes") or stripped.startswith(
+ "tab or esc to clear notes"
+ ):
+ break
+ return None
+
+
+async def _select_codex_prompt_option(
+ state: CodexPromptState,
+ option_index: int,
+) -> tuple[bool, bool]:
+ """Apply the current answer to the tmux Codex TUI and advance state."""
+ question = _current_codex_question(state)
+ if option_index < 0 or option_index > len(question.options):
+ return False, False
+
+ if not await _move_codex_prompt_cursor_to_option(
+ state.window_id, question, option_index
+ ):
+ return False, False
+
+ notes_text = state.notes.get(question.question_id, "").strip()
+ if notes_text:
+ if not await _send_special_key(state.window_id, "Tab"):
+ return False, False
+ await asyncio.sleep(0.2)
+ if not await tmux_manager.send_keys(
+ state.window_id,
+ notes_text,
+ enter=False,
+ literal=True,
+ ):
+ return False, False
+ await asyncio.sleep(0.2)
+
+ if not await _send_special_key(state.window_id, "Enter"):
+ return False, False
+
+ is_last = state.question_index >= len(state.prompt.questions) - 1
+ if not is_last:
+ state.question_index += 1
+ state.awaiting_notes_text = False
+ return True, is_last
+
+
+async def handle_codex_prompt(
+ bot: Bot,
+ user_id: int,
+ window_id: str,
+ prompt: CodexPromptPayload,
+ tool_use_id: str,
+ thread_id: int | None = None,
+) -> bool:
+ """Show or refresh a native Telegram panel for Codex request_user_input."""
+ ikey = _get_state_key(user_id, thread_id)
+ state = _codex_prompt_states.get(ikey)
+ if state and state.tool_use_id == tool_use_id:
+ state.prompt = prompt
+ state.window_id = window_id
+ else:
+ state = CodexPromptState(
+ window_id=window_id,
+ tool_use_id=tool_use_id,
+ prompt=prompt,
+ )
+ _codex_prompt_states[ikey] = state
+ return await _render_codex_prompt_message(bot, user_id, thread_id, state)
+
+
+async def advance_codex_prompt_with_option(
+ bot: Bot,
+ user_id: int,
+ window_id: str,
+ thread_id: int | None,
+ question_index: int,
+ option_index: int,
+) -> tuple[bool, str]:
+ """Record a Codex prompt option choice and advance the Telegram panel."""
+ state = get_codex_prompt_state(user_id, thread_id)
+ if state is None or state.window_id != window_id:
+ return False, "Prompt is no longer active"
+ if question_index != state.question_index:
+ return False, "Prompt is out of date"
+
+ question = _current_codex_question(state)
+ if option_index < 0 or option_index > len(question.options):
+ return False, "Invalid option"
+
+ if option_index == len(question.options):
+ answer_label = "None of the above"
+ else:
+ answer_label = question.options[option_index].label
+ state.answers[question.question_id] = answer_label
+
+ ok, completed = await _select_codex_prompt_option(state, option_index)
+ if not ok:
+ return False, "Failed to submit answer to Codex"
+
+ if completed:
+ await clear_interactive_msg(user_id, bot, thread_id)
+ return True, "Submitted"
+
+ rendered = await _render_codex_prompt_message(bot, user_id, thread_id, state)
+ if not rendered:
+ return False, "Failed to update prompt"
+ return True, "Saved"
+
+
+async def arm_codex_prompt_notes_text(
+ bot: Bot,
+ user_id: int,
+ window_id: str,
+ thread_id: int | None,
+ question_index: int,
+) -> tuple[bool, str]:
+ """Switch the active Codex prompt into note capture mode."""
+ state = get_codex_prompt_state(user_id, thread_id)
+ if state is None or state.window_id != window_id:
+ return False, "Prompt is no longer active"
+ if question_index != state.question_index:
+ return False, "Prompt is out of date"
+
+ state.awaiting_notes_text = True
+ rendered = await _render_codex_prompt_message(bot, user_id, thread_id, state)
+ if not rendered:
+ return False, "Failed to update prompt"
+ return True, "Send notes"
+
+
+async def submit_codex_prompt_notes_text(
+ bot: Bot,
+ user_id: int,
+ thread_id: int | None,
+ text: str,
+) -> tuple[bool, str]:
+ """Use the next user text message as the notes for the current Codex question."""
+ state = get_codex_prompt_state(user_id, thread_id)
+ if state is None or not state.awaiting_notes_text:
+ return False, "No pending notes prompt"
+
+ notes_text = text.strip()
+ if not notes_text:
+ return False, "Notes cannot be empty"
+
+ question = _current_codex_question(state)
+ state.notes[question.question_id] = notes_text
+ state.awaiting_notes_text = False
+
+ rendered = await _render_codex_prompt_message(bot, user_id, thread_id, state)
+ if not rendered:
+ return False, "Failed to update prompt"
+ return True, "Notes saved"
+
+
def _build_interactive_keyboard(
window_id: str,
ui_name: str = "",
@@ -192,6 +591,9 @@ async def handle_interactive_ui(
# Check if we have an existing interactive message to edit
existing_msg_id = _interactive_msgs.get(ikey)
if existing_msg_id:
+ if _interactive_text_cache.get(ikey) == text:
+ _interactive_mode[ikey] = window_id
+ return True
try:
await bot.edit_message_text(
chat_id=chat_id,
@@ -201,6 +603,7 @@ async def handle_interactive_ui(
link_preview_options=NO_LINK_PREVIEW,
)
_interactive_mode[ikey] = window_id
+ _interactive_text_cache[ikey] = text
return True
except Exception:
# Edit failed (message deleted, etc.) - clear stale msg_id and send new
@@ -228,6 +631,7 @@ async def handle_interactive_ui(
if sent:
_interactive_msgs[ikey] = sent.message_id
_interactive_mode[ikey] = window_id
+ _interactive_text_cache[ikey] = text
return True
return False
@@ -241,6 +645,8 @@ async def clear_interactive_msg(
ikey = (user_id, thread_id or 0)
msg_id = _interactive_msgs.pop(ikey, None)
_interactive_mode.pop(ikey, None)
+ _interactive_text_cache.pop(ikey, None)
+ _codex_prompt_states.pop(ikey, None)
logger.debug(
"Clear interactive msg: user=%d, thread=%s, msg_id=%s",
user_id,
diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py
index c4de1c6e..650308c7 100644
--- a/src/ccbot/handlers/status_polling.py
+++ b/src/ccbot/handlers/status_polling.py
@@ -30,6 +30,7 @@
clear_interactive_msg,
get_interactive_window,
handle_interactive_ui,
+ has_codex_prompt,
)
from .cleanup import clear_topic_state
from .message_queue import enqueue_status_update, get_message_queue
@@ -73,13 +74,18 @@ async def update_status_message(
# Transient capture failure - keep existing status message
return
+ if has_codex_prompt(user_id, thread_id):
+ return
+
interactive_window = get_interactive_window(user_id, thread_id)
should_check_new_ui = True
if interactive_window == window_id:
# User is in interactive mode for THIS window
if is_interactive_ui(pane_text):
- # Interactive UI still showing — skip status update (user is interacting)
+ # Interactive UI still showing — refresh the existing Telegram panel
+ # in case the terminal advanced to a new step (e.g. /model stage 2).
+ await handle_interactive_ui(bot, user_id, window_id, thread_id)
return
# Interactive UI gone — clear interactive mode, fall through to status check.
# Don't re-check for new UI this cycle (the old one just disappeared).
diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py
index eaf9f411..0b724a4c 100644
--- a/src/ccbot/hook.py
+++ b/src/ccbot/hook.py
@@ -1,18 +1,14 @@
-"""Hook subcommand for Claude Code session tracking.
+"""Hook subcommand for Claude Code or Codex session tracking.
-Called by Claude Code's SessionStart hook to maintain a window↔session
-mapping in /session_map.json. Also provides `--install` to
-auto-configure the hook in ~/.claude/settings.json.
+Supports:
+ - `ccbot hook --install` to install hooks for Claude Code or Codex
+ - `ccbot hook --install-codex` as a backwards-compatible Codex alias
-This module must NOT import config.py (which requires TELEGRAM_BOT_TOKEN),
-since hooks run inside tmux panes where bot env vars are not set.
-Config directory resolution uses utils.ccbot_dir() (shared with config.py).
-
-Key functions: hook_main() (CLI entry), _install_hook().
+At runtime, the hook reads a `SessionStart` payload from stdin and updates
+`/session_map.json` with the current tmux window metadata.
"""
import argparse
-import fcntl
import json
import logging
import os
@@ -22,118 +18,187 @@
import sys
from pathlib import Path
+from .runtimes import RUNTIME_CLAUDE, RUNTIME_CODEX
+from .session_register import register_session
+
logger = logging.getLogger(__name__)
-# Validate session_id looks like a UUID
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
-
_CLAUDE_SETTINGS_FILE = Path.home() / ".claude" / "settings.json"
-
-# The hook command suffix for detection
_HOOK_COMMAND_SUFFIX = "ccbot hook"
-def _find_ccbot_path() -> str:
- """Find the full path to the ccbot executable.
+def _codex_hooks_file() -> Path:
+ codex_home = os.getenv("CODEX_HOME", "").strip()
+ base = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex"
+ return base / "hooks.json"
+
- Priority:
- 1. shutil.which("ccbot") - if ccbot is in PATH
- 2. Same directory as the Python interpreter (for venv installs)
- """
- # Try PATH first
+def _find_ccbot_path() -> str:
+ """Find the full path to the ccbot executable."""
ccbot_path = shutil.which("ccbot")
if ccbot_path:
return ccbot_path
- # Fall back to the directory containing the Python interpreter
- # This handles the case where ccbot is installed in a venv
python_dir = Path(sys.executable).parent
ccbot_in_venv = python_dir / "ccbot"
if ccbot_in_venv.exists():
return str(ccbot_in_venv)
- # Last resort: assume it will be in PATH
return "ccbot"
-def _is_hook_installed(settings: dict) -> bool:
- """Check if ccbot hook is already installed in the settings.
-
- Detects both 'ccbot hook' and full paths like '/path/to/ccbot hook'.
- """
- hooks = settings.get("hooks", {})
- session_start = hooks.get("SessionStart", [])
-
- for entry in session_start:
+def _has_command_hook(entries: list[object]) -> bool:
+ for entry in entries:
if not isinstance(entry, dict):
continue
- inner_hooks = entry.get("hooks", [])
- for h in inner_hooks:
- if not isinstance(h, dict):
+ for hook in entry.get("hooks", []):
+ if not isinstance(hook, dict):
continue
- cmd = h.get("command", "")
- # Match 'ccbot hook' or paths ending with 'ccbot hook'
+ cmd = hook.get("command", "")
if cmd == _HOOK_COMMAND_SUFFIX or cmd.endswith("/" + _HOOK_COMMAND_SUFFIX):
return True
return False
-def _install_hook() -> int:
- """Install the ccbot hook into Claude's settings.json.
+def _read_json_file(path: Path) -> dict:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text())
+ except (json.JSONDecodeError, OSError) as e:
+ logger.error("Error reading %s: %s", path, e)
+ raise
+ if not isinstance(data, dict):
+ raise ValueError(f"{path} must contain a JSON object")
+ return data
+
- Returns 0 on success, 1 on error.
- """
+def _write_json_file(path: Path, data: dict) -> int:
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
+ except OSError as e:
+ logger.error("Error writing %s: %s", path, e)
+ print(f"Error writing {path}: {e}", file=sys.stderr)
+ return 1
+ return 0
+
+
+def _install_claude_hook() -> int:
+ """Install the hook into Claude's settings.json."""
settings_file = _CLAUDE_SETTINGS_FILE
- settings_file.parent.mkdir(parents=True, exist_ok=True)
-
- # Read existing settings
- settings: dict = {}
- if settings_file.exists():
- try:
- settings = json.loads(settings_file.read_text())
- except (json.JSONDecodeError, OSError) as e:
- logger.error("Error reading %s: %s", settings_file, e)
- print(f"Error reading {settings_file}: {e}", file=sys.stderr)
- return 1
-
- # Check if already installed
- if _is_hook_installed(settings):
+ try:
+ settings = _read_json_file(settings_file)
+ except (json.JSONDecodeError, OSError, ValueError) as e:
+ print(f"Error reading {settings_file}: {e}", file=sys.stderr)
+ return 1
+
+ hooks = settings.setdefault("hooks", {})
+ session_start = hooks.setdefault("SessionStart", [])
+ if not isinstance(session_start, list):
+ print(f"Error: {settings_file} hooks.SessionStart must be a list", file=sys.stderr)
+ return 1
+
+ if _has_command_hook(session_start):
logger.info("Hook already installed in %s", settings_file)
print(f"Hook already installed in {settings_file}")
return 0
- # Find the full path to ccbot
ccbot_path = _find_ccbot_path()
- hook_command = f"{ccbot_path} hook"
- hook_config = {"type": "command", "command": hook_command, "timeout": 5}
- logger.info("Installing hook command: %s", hook_command)
+ hook_config = {
+ "type": "command",
+ "command": f"{ccbot_path} hook",
+ "timeout": 5,
+ }
+ session_start.append({"hooks": [hook_config]})
+ result = _write_json_file(settings_file, settings)
+ if result == 0:
+ logger.info("Hook installed successfully in %s", settings_file)
+ print(f"Hook installed successfully in {settings_file}")
+ return result
+
+
+def _install_codex_hook() -> int:
+ """Install the hook into Codex's hooks.json."""
+ hooks_file = _codex_hooks_file()
+ try:
+ settings = _read_json_file(hooks_file)
+ except (json.JSONDecodeError, OSError, ValueError) as e:
+ print(f"Error reading {hooks_file}: {e}", file=sys.stderr)
+ return 1
- # Install the hook
- if "hooks" not in settings:
- settings["hooks"] = {}
- if "SessionStart" not in settings["hooks"]:
- settings["hooks"]["SessionStart"] = []
+ migrated = False
- settings["hooks"]["SessionStart"].append({"hooks": [hook_config]})
+ hooks = settings.setdefault("hooks", {})
+ if not isinstance(hooks, dict):
+ print(f"Error: {hooks_file} hooks must be an object", file=sys.stderr)
+ return 1
- # Write back
- try:
- settings_file.write_text(
- json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
- )
- except OSError as e:
- logger.error("Error writing %s: %s", settings_file, e)
- print(f"Error writing {settings_file}: {e}", file=sys.stderr)
+ legacy_session_start = settings.pop("SessionStart", None)
+ if legacy_session_start is not None:
+ migrated = True
+ if "SessionStart" not in hooks:
+ hooks["SessionStart"] = legacy_session_start
+
+ session_start = hooks.setdefault("SessionStart", [])
+ if not isinstance(session_start, list):
+ print(f"Error: {hooks_file} hooks.SessionStart must be a list", file=sys.stderr)
return 1
- logger.info("Hook installed successfully in %s", settings_file)
- print(f"Hook installed successfully in {settings_file}")
- return 0
+ if _has_command_hook(session_start):
+ if migrated:
+ result = _write_json_file(hooks_file, settings)
+ if result != 0:
+ return result
+ logger.info("Hook already installed in %s", hooks_file)
+ print(f"Hook already installed in {hooks_file}")
+ return 0
+
+ ccbot_path = _find_ccbot_path()
+ hook_config = {
+ "type": "command",
+ "command": f"{ccbot_path} hook",
+ "timeout": 5,
+ }
+ session_start.append({"hooks": [hook_config]})
+ result = _write_json_file(hooks_file, settings)
+ if result == 0:
+ logger.info("Hook installed successfully in %s", hooks_file)
+ print(f"Hook installed successfully in {hooks_file}")
+ return result
+
+
+def _resolve_install_targets(explicit_runtime: str | None) -> list[str]:
+ """Resolve which Claude Code / Codex hook targets to install."""
+ if explicit_runtime == "all":
+ return [RUNTIME_CLAUDE, RUNTIME_CODEX]
+ if explicit_runtime in {RUNTIME_CLAUDE, RUNTIME_CODEX}:
+ return [explicit_runtime]
+
+ env_runtime = os.getenv("CCBOT_RUNTIME", "").strip()
+ if env_runtime in {RUNTIME_CLAUDE, RUNTIME_CODEX}:
+ return [env_runtime]
+
+ return [RUNTIME_CLAUDE, RUNTIME_CODEX]
+
+
+def _install_selected_hooks(explicit_runtime: str | None) -> int:
+ """Install hooks for the selected Claude Code / Codex targets."""
+ installers = {
+ RUNTIME_CLAUDE: _install_claude_hook,
+ RUNTIME_CODEX: _install_codex_hook,
+ }
+ status = 0
+ for target in _resolve_install_targets(explicit_runtime):
+ result = installers[target]()
+ if result != 0:
+ status = result
+ return status
def hook_main() -> None:
- """Process a Claude Code hook event from stdin, or install the hook."""
- # Configure logging for the hook subprocess (main.py logging doesn't apply here)
+ """Process a Claude Code / Codex hook event from stdin, or install hooks."""
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.DEBUG,
@@ -142,21 +207,37 @@ def hook_main() -> None:
parser = argparse.ArgumentParser(
prog="ccbot hook",
- description="Claude Code session tracking hook",
+ description="Claude Code / Codex session tracking hook",
)
parser.add_argument(
"--install",
action="store_true",
- help="Install the hook into ~/.claude/settings.json",
+ help=(
+ "Install hooks for Claude Code or Codex. "
+ "Uses --run/--runtime, then CCBOT_RUNTIME, otherwise installs both."
+ ),
+ )
+ parser.add_argument(
+ "--run",
+ "--runtime",
+ dest="runtime",
+ choices=[RUNTIME_CLAUDE, RUNTIME_CODEX, "all"],
+ help="Hook target for --install: claude, codex, or all",
+ )
+ parser.add_argument(
+ "--install-codex",
+ action="store_true",
+ help="Backwards-compatible alias for --install --run codex",
)
- # Parse only known args to avoid conflicts with stdin JSON
args, _ = parser.parse_known_args(sys.argv[2:])
if args.install:
- logger.info("Hook install requested")
- sys.exit(_install_hook())
+ logger.info("Hook install requested for %s", args.runtime or "auto")
+ sys.exit(_install_selected_hooks(args.runtime))
+ if args.install_codex:
+ logger.info("Codex hook install requested")
+ sys.exit(_install_codex_hook())
- # Normal hook processing: read JSON from stdin
logger.debug("Processing hook event from stdin")
try:
payload = json.load(sys.stdin)
@@ -164,30 +245,30 @@ def hook_main() -> None:
logger.warning("Failed to parse stdin JSON: %s", e)
return
- session_id = payload.get("session_id", "")
+ session_id = payload.get("session_id") or payload.get("id", "")
cwd = payload.get("cwd", "")
event = payload.get("hook_event_name", "")
+ transcript_path = payload.get("transcript_path", "")
if not session_id or not event:
logger.debug("Empty session_id or event, ignoring")
return
- # Validate session_id format
if not _UUID_RE.match(session_id):
logger.warning("Invalid session_id format: %s", session_id)
return
- # Validate cwd is an absolute path (if provided)
if cwd and not os.path.isabs(cwd):
logger.warning("cwd is not absolute: %s", cwd)
return
+ if transcript_path and not os.path.isabs(transcript_path):
+ logger.warning("transcript_path is not absolute: %s", transcript_path)
+ return
if event != "SessionStart":
logger.debug("Ignoring non-SessionStart event: %s", event)
return
- # Get tmux session:window key for the pane running this hook.
- # TMUX_PANE is set by tmux for every process inside a pane.
pane_id = os.environ.get("TMUX_PANE", "")
if not pane_id:
logger.warning("TMUX_PANE not set, cannot determine window")
@@ -206,7 +287,6 @@ def hook_main() -> None:
text=True,
)
raw_output = result.stdout.strip()
- # Expected format: "session_name:@id:window_name"
parts = raw_output.split(":", 2)
if len(parts) < 3:
logger.warning(
@@ -215,62 +295,36 @@ def hook_main() -> None:
raw_output,
)
return
+
tmux_session_name, window_id, window_name = parts
- # Key uses window_id for uniqueness
- session_window_key = f"{tmux_session_name}:{window_id}"
+ runtime = os.getenv("CCBOT_RUNTIME", "").strip()
+ if runtime not in {RUNTIME_CLAUDE, RUNTIME_CODEX}:
+ runtime = RUNTIME_CODEX if transcript_path else RUNTIME_CLAUDE
logger.debug(
- "tmux key=%s, window_name=%s, session_id=%s, cwd=%s",
- session_window_key,
+ "tmux key=%s:%s, window_name=%s, session_id=%s, cwd=%s, runtime=%s, transcript=%s",
+ tmux_session_name,
+ window_id,
window_name,
session_id,
cwd,
+ runtime,
+ transcript_path,
)
- # Read-modify-write with file locking to prevent concurrent hook races
- from .utils import ccbot_dir
-
- map_file = ccbot_dir() / "session_map.json"
- map_file.parent.mkdir(parents=True, exist_ok=True)
-
- lock_path = map_file.with_suffix(".lock")
- try:
- with open(lock_path, "w") as lock_f:
- fcntl.flock(lock_f, fcntl.LOCK_EX)
- logger.debug("Acquired lock on %s", lock_path)
- try:
- session_map: dict[str, dict[str, str]] = {}
- if map_file.exists():
- try:
- session_map = json.loads(map_file.read_text())
- except (json.JSONDecodeError, OSError):
- logger.warning(
- "Failed to read existing session_map, starting fresh"
- )
-
- session_map[session_window_key] = {
- "session_id": session_id,
- "cwd": cwd,
- "window_name": window_name,
- }
-
- # Clean up old-format key ("session:window_name") if it exists.
- # Previous versions keyed by window_name instead of window_id.
- old_key = f"{tmux_session_name}:{window_name}"
- if old_key != session_window_key and old_key in session_map:
- del session_map[old_key]
- logger.info("Removed old-format session_map key: %s", old_key)
-
- from .utils import atomic_write_json
-
- atomic_write_json(map_file, session_map)
- logger.info(
- "Updated session_map: %s -> session_id=%s, cwd=%s",
- session_window_key,
- session_id,
- cwd,
- )
- finally:
- fcntl.flock(lock_f, fcntl.LOCK_UN)
- except OSError as e:
- logger.error("Failed to write session_map: %s", e)
+ ok = register_session(
+ window_id=window_id,
+ session_id=session_id,
+ cwd=cwd,
+ window_name=window_name,
+ runtime=runtime,
+ transcript_path=transcript_path,
+ tmux_session_name=tmux_session_name,
+ )
+ if not ok:
+ logger.error(
+ "Failed to register runtime session for %s:%s (%s)",
+ tmux_session_name,
+ window_id,
+ session_id,
+ )
diff --git a/src/ccbot/main.py b/src/ccbot/main.py
index dabd3fd7..8eee9dec 100644
--- a/src/ccbot/main.py
+++ b/src/ccbot/main.py
@@ -1,22 +1,157 @@
"""Application entry point — CLI dispatcher and bot bootstrap.
-Handles two execution modes:
- 1. `ccbot hook` — delegates to hook.hook_main() for Claude Code hook processing.
- 2. Default — configures logging, initializes tmux session, and starts the
+Handles three execution modes:
+ 1. `ccbot hook` — delegates to hook.hook_main() for Claude Code / Codex hook processing.
+ 2. `ccbot session-register` — registers tmux window -> Claude Code / Codex session metadata.
+ 3. Default — applies top-level CLI overrides, configures logging, initializes tmux session, and starts the
Telegram bot polling loop via bot.create_bot().
"""
+import argparse
import logging
+import os
+import re
+import shlex
+import subprocess
import sys
+from .runtimes import RUNTIME_CODEX, SUPPORTED_RUNTIMES
+
+_MIN_CODEX_VERSION = (0, 114, 0)
+_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
+
+
+def _apply_global_cli_overrides(argv: list[str]) -> list[str]:
+ """Apply global CLI overrides and return the remaining argv.
+
+ Supports `ccbot --run claude|codex` so users can switch between
+ Claude Code and Codex without editing environment variables.
+ """
+ parser = argparse.ArgumentParser(
+ prog="ccbot",
+ add_help=False,
+ )
+ parser.add_argument(
+ "--run",
+ "--runtime",
+ dest="runtime",
+ choices=sorted(SUPPORTED_RUNTIMES),
+ help="Runtime (Claude Code or Codex) for this bot process",
+ )
+ parser.add_argument("-h", "--help", action="store_true")
+
+ args, remaining = parser.parse_known_args(argv[1:])
+
+ if args.runtime:
+ os.environ["CCBOT_RUNTIME"] = args.runtime
+
+ if remaining and remaining[0] in {"hook", "session-register"}:
+ return [argv[0], *remaining]
+
+ if args.help:
+ help_parser = argparse.ArgumentParser(
+ prog="ccbot",
+ description="Telegram bot for tmux-backed Claude Code or Codex sessions",
+ )
+ help_parser.add_argument(
+ "--run",
+ "--runtime",
+ dest="runtime",
+ choices=sorted(SUPPORTED_RUNTIMES),
+ help="Runtime (Claude Code or Codex) for this bot process",
+ )
+ help_parser.print_help()
+ raise SystemExit(0)
+
+ if remaining:
+ parser.error(f"unrecognized arguments: {' '.join(remaining)}")
+
+ return [argv[0]]
+
+
+def _build_codex_version_command(codex_command: str) -> list[str]:
+ """Build the command used to query the Codex CLI version."""
+ parts = shlex.split(codex_command)
+ if not parts:
+ raise ValueError("CODEX_COMMAND is empty")
+
+ for index, token in enumerate(parts):
+ if os.path.basename(token).startswith("codex"):
+ return [*parts[: index + 1], "--version"]
+
+ return [parts[0], "--version"]
+
+
+def _parse_version(output: str) -> tuple[int, int, int] | None:
+ """Extract a semantic version triple from command output."""
+ match = _VERSION_RE.search(output)
+ if not match:
+ return None
+ major, minor, patch = match.groups()
+ return (int(major), int(minor), int(patch))
+
+
+def _ensure_runtime_requirements(config: object) -> None:
+ """Fail early when the selected Claude Code / Codex runtime is unsupported."""
+ runtime = getattr(config, "runtime", "")
+ if runtime != RUNTIME_CODEX:
+ return
+
+ codex_command = str(getattr(config, "codex_command", "")).strip()
+ version_cmd = _build_codex_version_command(codex_command)
+
+ try:
+ result = subprocess.run(
+ version_cmd,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ except OSError as exc:
+ raise ValueError(
+ "Unable to run the Codex version check. "
+ f"Tried: {' '.join(version_cmd)}. Error: {exc}"
+ ) from exc
+
+ output = (result.stdout or result.stderr or "").strip()
+ if result.returncode != 0:
+ raise ValueError(
+ "Unable to detect the Codex version. "
+ f"Tried: {' '.join(version_cmd)}. Output: {output or '(empty)'}"
+ )
+
+ parsed = _parse_version(output)
+ if parsed is None:
+ raise ValueError(
+ "Unable to parse the Codex version output. "
+ f"Tried: {' '.join(version_cmd)}. Output: {output or '(empty)'}"
+ )
+
+ if parsed < _MIN_CODEX_VERSION:
+ current = ".".join(str(part) for part in parsed)
+ required = ".".join(str(part) for part in _MIN_CODEX_VERSION)
+ raise ValueError(
+ "Codex support requires codex-cli "
+ f">= {required}, but detected {current}. "
+ "Please upgrade Codex and retry."
+ )
+
def main() -> None:
"""Main entry point."""
- if len(sys.argv) > 1 and sys.argv[1] == "hook":
+ argv = _apply_global_cli_overrides(sys.argv)
+ sys.argv = argv
+
+ if len(argv) > 1 and argv[1] == "hook":
from .hook import hook_main
hook_main()
return
+ if len(argv) > 1 and argv[1] == "session-register":
+ from .session_register import session_register_main
+
+ session_register_main()
+ return
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -40,6 +175,12 @@ def main() -> None:
print("Get your user ID from @userinfobot on Telegram.")
sys.exit(1)
+ try:
+ _ensure_runtime_requirements(config)
+ except ValueError as e:
+ print(f"Error: {e}")
+ sys.exit(1)
+
logging.getLogger("ccbot").setLevel(logging.DEBUG)
# AIORateLimiter (max_retries=5) handles retries itself; keep INFO for visibility
logging.getLogger("telegram.ext.AIORateLimiter").setLevel(logging.INFO)
@@ -48,7 +189,9 @@ def main() -> None:
from .tmux_manager import tmux_manager
logger.info("Allowed users: %s", config.allowed_users)
+ logger.info("Runtime (Claude Code or Codex): %s", config.runtime)
logger.info("Claude projects path: %s", config.claude_projects_path)
+ logger.info("Codex home: %s", config.codex_home)
# Ensure tmux session exists
session = tmux_manager.get_or_create_session()
diff --git a/src/ccbot/runtimes.py b/src/ccbot/runtimes.py
new file mode 100644
index 00000000..c85a7cec
--- /dev/null
+++ b/src/ccbot/runtimes.py
@@ -0,0 +1,6 @@
+"""Supported runtime identifiers shared across the codebase."""
+
+RUNTIME_CLAUDE = "claude"
+RUNTIME_CODEX = "codex"
+
+SUPPORTED_RUNTIMES = {RUNTIME_CLAUDE, RUNTIME_CODEX}
diff --git a/src/ccbot/session.py b/src/ccbot/session.py
index 173293b1..d095d74e 100644
--- a/src/ccbot/session.py
+++ b/src/ccbot/session.py
@@ -1,13 +1,13 @@
-"""Claude Code session management — the core state hub.
+"""Runtime session management — the core state hub.
Manages the key mappings:
- Window→Session (window_states): which Claude session_id a window holds (keyed by window_id).
+ Window→Session (window_states): which runtime session_id a window holds (keyed by window_id).
User→Thread→Window (thread_bindings): topic-to-window bindings (1 topic = 1 window_id).
Responsibilities:
- Persist/load state to ~/.ccbot/state.json.
- Sync window↔session bindings from session_map.json (written by hook).
- - Resolve window IDs to ClaudeSession objects (JSONL file reading).
+ - Resolve window IDs to session objects (JSONL file reading).
- Track per-user read offsets for unread-message detection.
- Manage thread↔window bindings for Telegram topic routing.
- Send keystrokes to tmux windows and retrieve message history.
@@ -33,9 +33,10 @@
import aiofiles
from .config import config
+from .runtimes import RUNTIME_CLAUDE, RUNTIME_CODEX
from .tmux_manager import tmux_manager
from .transcript_parser import TranscriptParser
-from .utils import atomic_write_json
+from .utils import atomic_write_json, read_cwd_from_jsonl
logger = logging.getLogger(__name__)
@@ -45,22 +46,29 @@ class WindowState:
"""Persistent state for a tmux window.
Attributes:
- session_id: Associated Claude session ID (empty if not yet detected)
+ session_id: Associated runtime session ID (empty if not yet detected)
cwd: Working directory for direct file path construction
window_name: Display name of the window
+ runtime: Runtime name associated with the session
+ transcript_path: Optional absolute path to the runtime transcript file
"""
session_id: str = ""
cwd: str = ""
window_name: str = ""
+ runtime: str = RUNTIME_CLAUDE
+ transcript_path: str = ""
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"session_id": self.session_id,
"cwd": self.cwd,
+ "runtime": self.runtime,
}
if self.window_name:
d["window_name"] = self.window_name
+ if self.transcript_path:
+ d["transcript_path"] = self.transcript_path
return d
@classmethod
@@ -69,12 +77,14 @@ def from_dict(cls, data: dict[str, Any]) -> "WindowState":
session_id=data.get("session_id", ""),
cwd=data.get("cwd", ""),
window_name=data.get("window_name", ""),
+ runtime=data.get("runtime", RUNTIME_CLAUDE),
+ transcript_path=data.get("transcript_path", ""),
)
@dataclass
class ClaudeSession:
- """Information about a Claude Code session."""
+ """Information about a persisted runtime session."""
session_id: str
summary: str
@@ -84,7 +94,7 @@ class ClaudeSession:
@dataclass
class SessionManager:
- """Manages session state for Claude Code.
+ """Manages session state for tmux-backed runtimes.
All internal keys use window_id (e.g. '@0', '@12') for uniqueness.
Display names (window_name) are stored separately for UI presentation.
@@ -528,18 +538,28 @@ async def load_session_map(self) -> None:
new_sid = info.get("session_id", "")
new_cwd = info.get("cwd", "")
new_wname = info.get("window_name", "")
+ new_runtime = info.get("runtime", RUNTIME_CLAUDE)
+ new_transcript_path = info.get("transcript_path", "")
if not new_sid:
continue
state = self.get_window_state(window_id)
- if state.session_id != new_sid or state.cwd != new_cwd:
+ if (
+ state.session_id != new_sid
+ or state.cwd != new_cwd
+ or state.runtime != new_runtime
+ or state.transcript_path != new_transcript_path
+ ):
logger.info(
- "Session map: window_id %s updated sid=%s, cwd=%s",
+ "Session map: window_id %s updated sid=%s runtime=%s cwd=%s",
window_id,
new_sid,
+ new_runtime,
new_cwd,
)
state.session_id = new_sid
state.cwd = new_cwd
+ state.runtime = new_runtime
+ state.transcript_path = new_transcript_path
changed = True
# Update display name
if new_wname:
@@ -570,9 +590,27 @@ def clear_window_session(self, window_id: str) -> None:
"""Clear session association for a window (e.g., after /clear command)."""
state = self.get_window_state(window_id)
state.session_id = ""
+ state.runtime = config.runtime
+ state.transcript_path = ""
self._save_state()
logger.info("Cleared session for window_id %s", window_id)
+ def remove_window(self, window_id: str) -> None:
+ """Remove persisted state for a tmux window that no longer exists."""
+ self.window_states.pop(window_id, None)
+ self.window_display_names.pop(window_id, None)
+
+ empty_users: list[int] = []
+ for user_id, offsets in self.user_window_offsets.items():
+ offsets.pop(window_id, None)
+ if not offsets:
+ empty_users.append(user_id)
+ for user_id in empty_users:
+ del self.user_window_offsets[user_id]
+
+ self._save_state()
+ logger.info("Removed state for window_id %s", window_id)
+
@staticmethod
def _encode_cwd(cwd: str) -> str:
"""Encode a cwd path to match Claude Code's project directory naming.
@@ -582,21 +620,64 @@ def _encode_cwd(cwd: str) -> str:
"""
return re.sub(r"[^a-zA-Z0-9-]", "-", cwd)
- def _build_session_file_path(self, session_id: str, cwd: str) -> Path | None:
- """Build the direct file path for a session from session_id and cwd."""
+ def _build_claude_session_file_path(self, session_id: str, cwd: str) -> Path | None:
+ """Build the direct Claude transcript path for a session from session_id and cwd."""
if not session_id or not cwd:
return None
encoded_cwd = self._encode_cwd(cwd)
return config.claude_projects_path / encoded_cwd / f"{session_id}.jsonl"
+ def _build_codex_session_file_path(
+ self, session_id: str, transcript_path: str = ""
+ ) -> Path | None:
+ """Build the direct Codex transcript path for a session."""
+ if transcript_path:
+ return Path(transcript_path)
+ if not session_id:
+ return None
+ if not config.codex_sessions_path.is_dir():
+ return None
+
+ matches = sorted(
+ config.codex_sessions_path.rglob(f"*{session_id}.jsonl"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+ return matches[0] if matches else None
+
+ def resolve_session_file_path(
+ self,
+ session_id: str,
+ cwd: str,
+ *,
+ runtime: str = RUNTIME_CLAUDE,
+ transcript_path: str = "",
+ ) -> Path | None:
+ """Resolve the transcript file path for the configured runtime."""
+ if runtime == RUNTIME_CODEX:
+ return self._build_codex_session_file_path(
+ session_id, transcript_path=transcript_path
+ )
+ return self._build_claude_session_file_path(session_id, cwd)
+
async def _get_session_direct(
- self, session_id: str, cwd: str
+ self,
+ session_id: str,
+ cwd: str,
+ *,
+ runtime: str = RUNTIME_CLAUDE,
+ transcript_path: str = "",
) -> ClaudeSession | None:
- """Get a ClaudeSession directly from session_id and cwd (no scanning)."""
- file_path = self._build_session_file_path(session_id, cwd)
+ """Get a session directly from session_id + runtime metadata (no scanning)."""
+ file_path = self.resolve_session_file_path(
+ session_id,
+ cwd,
+ runtime=runtime,
+ transcript_path=transcript_path,
+ )
- # Fallback: glob search if direct path doesn't exist
- if not file_path or not file_path.exists():
+ # Fallback: glob search if direct Claude path doesn't exist
+ if runtime == RUNTIME_CLAUDE and (not file_path or not file_path.exists()):
pattern = f"*/{session_id}.jsonl"
matches = list(config.claude_projects_path.glob(pattern))
if matches:
@@ -604,8 +685,11 @@ async def _get_session_direct(
logger.debug("Found session via glob: %s", file_path)
else:
return None
+ elif not file_path or not file_path.exists():
+ return None
# Single pass: read file once, extract summary + count messages
+ resolved_session_id = session_id
summary = ""
last_user_msg = ""
message_count = 0
@@ -618,12 +702,22 @@ async def _get_session_direct(
message_count += 1
try:
data = json.loads(line)
- # Check for summary
- if data.get("type") == "summary":
+ if runtime == RUNTIME_CODEX and data.get("type") == "session_meta":
+ payload = data.get("payload", {})
+ if isinstance(payload, dict):
+ payload_id = str(payload.get("id", "")).strip()
+ if payload_id:
+ resolved_session_id = payload_id
+ s = (
+ str(payload.get("thread_name", "")).strip()
+ or str(payload.get("title", "")).strip()
+ )
+ if s:
+ summary = s
+ elif data.get("type") == "summary":
s = data.get("summary", "")
if s:
summary = s
- # Track last user message as fallback
elif TranscriptParser.is_user_message(data):
parsed = TranscriptParser.parse_message(data)
if parsed and parsed.text.strip():
@@ -637,7 +731,7 @@ async def _get_session_direct(
summary = last_user_msg[:50] if last_user_msg else "Untitled"
return ClaudeSession(
- session_id=session_id,
+ session_id=resolved_session_id,
summary=summary,
message_count=message_count,
file_path=str(file_path),
@@ -646,14 +740,37 @@ async def _get_session_direct(
# --- Directory session listing ---
async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]:
- """List existing Claude sessions for a directory.
-
- Encodes the cwd path to find the project directory under
- ~/.claude/projects/{encoded_cwd}/, globs *.jsonl files, and
- extracts summary info from each.
+ """List existing runtime sessions for a directory.
- Returns a list sorted by mtime (most recent first), capped at 10.
+ Uses the active runtime's transcript storage and returns sessions sorted
+ by mtime (most recent first), capped at 10.
"""
+ if config.runtime == RUNTIME_CODEX:
+ if not config.codex_sessions_path.is_dir():
+ return []
+
+ sessions: list[ClaudeSession] = []
+ jsonl_files = sorted(
+ config.codex_sessions_path.rglob("*.jsonl"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+ for transcript_file in jsonl_files:
+ if len(sessions) >= 10:
+ break
+ session = await self._get_session_direct(
+ transcript_file.stem,
+ cwd,
+ runtime=RUNTIME_CODEX,
+ transcript_path=str(transcript_file),
+ )
+ if not session:
+ continue
+ file_cwd = read_cwd_from_jsonl(transcript_file)
+ if file_cwd == cwd and session.message_count > 0:
+ sessions.append(session)
+ return sessions
+
encoded_cwd = self._encode_cwd(cwd)
project_dir = config.claude_projects_path / encoded_cwd
if not project_dir.is_dir():
@@ -674,7 +791,9 @@ async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]:
if len(sessions) >= 10:
break
session_id = f.stem
- session = await self._get_session_direct(session_id, cwd)
+ session = await self._get_session_direct(
+ session_id, cwd, runtime=RUNTIME_CLAUDE
+ )
if session and session.message_count > 0:
sessions.append(session)
return sessions
@@ -682,7 +801,7 @@ async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]:
# --- Window → Session resolution ---
async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | None:
- """Resolve a tmux window to the best matching Claude session.
+ """Resolve a tmux window to the best matching runtime session.
Uses persisted session_id + cwd to construct file path directly.
Returns None if no session is associated with this window.
@@ -692,7 +811,12 @@ async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | No
if not state.session_id or not state.cwd:
return None
- session = await self._get_session_direct(state.session_id, state.cwd)
+ session = await self._get_session_direct(
+ state.session_id,
+ state.cwd,
+ runtime=state.runtime,
+ transcript_path=state.transcript_path,
+ )
if session:
return session
@@ -705,6 +829,8 @@ async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | No
)
state.session_id = ""
state.cwd = ""
+ state.runtime = config.runtime
+ state.transcript_path = ""
self._save_state()
return None
diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py
index 0a1b3186..44534efc 100644
--- a/src/ccbot/session_monitor.py
+++ b/src/ccbot/session_monitor.py
@@ -1,4 +1,4 @@
-"""Session monitoring service — watches JSONL files for new messages.
+"""Session monitoring service — watches runtime JSONL files for new messages.
Runs an async polling loop that:
1. Loads the current session_map to know which sessions to watch.
@@ -22,8 +22,9 @@
from .config import config
from .monitor_state import MonitorState, TrackedSession
+from .runtimes import RUNTIME_CLAUDE
from .tmux_manager import tmux_manager
-from .transcript_parser import TranscriptParser
+from .transcript_parser import CodexPromptPayload, TranscriptParser
from .utils import read_cwd_from_jsonl
logger = logging.getLogger(__name__)
@@ -31,7 +32,7 @@
@dataclass
class SessionInfo:
- """Information about a Claude Code session."""
+ """Information about a runtime session."""
session_id: str
file_path: Path
@@ -49,10 +50,11 @@ class NewMessage:
role: str = "assistant" # "user" or "assistant"
tool_name: str | None = None # For tool_use messages, the tool name
image_data: list[tuple[str, bytes]] | None = None # From tool_result images
+ interactive_prompt: CodexPromptPayload | None = None
class SessionMonitor:
- """Monitors Claude Code sessions for new assistant messages.
+ """Monitors runtime sessions for new assistant messages.
Uses simple async polling with aiofiles for non-blocking I/O.
Emits both intermediate and complete assistant messages.
@@ -102,7 +104,7 @@ async def _get_active_cwds(self) -> set[str]:
return cwds
async def scan_projects(self) -> list[SessionInfo]:
- """Scan projects that have active tmux windows."""
+ """Scan Claude projects that have active tmux windows."""
active_cwds = await self._get_active_cwds()
if not active_cwds:
return []
@@ -193,6 +195,39 @@ async def scan_projects(self) -> list[SessionInfo]:
return sessions
+ async def _resolve_active_sessions(
+ self, active_session_ids: set[str]
+ ) -> list[SessionInfo]:
+ """Resolve transcript files for the active runtime sessions."""
+ if config.runtime == RUNTIME_CLAUDE:
+ return await self.scan_projects()
+
+ from .session import session_manager
+
+ sessions: list[SessionInfo] = []
+ seen: set[str] = set()
+ for state in session_manager.window_states.values():
+ if not state.session_id or state.session_id not in active_session_ids:
+ continue
+ if state.session_id in seen:
+ continue
+ file_path = session_manager.resolve_session_file_path(
+ state.session_id,
+ state.cwd,
+ runtime=state.runtime,
+ transcript_path=state.transcript_path,
+ )
+ if not file_path or not file_path.exists():
+ continue
+ sessions.append(
+ SessionInfo(
+ session_id=state.session_id,
+ file_path=file_path,
+ )
+ )
+ seen.add(state.session_id)
+ return sessions
+
async def _read_new_lines(
self, session: TrackedSession, file_path: Path
) -> list[dict]:
@@ -277,8 +312,7 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa
"""
new_messages = []
- # Scan projects to get available session files
- sessions = await self.scan_projects()
+ sessions = await self._resolve_active_sessions(active_session_ids)
# Only process sessions that are in session_map
for session_info in sessions:
@@ -361,6 +395,7 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa
role=entry.role,
tool_name=entry.tool_name,
image_data=entry.image_data,
+ interactive_prompt=entry.interactive_prompt,
)
)
diff --git a/src/ccbot/session_register.py b/src/ccbot/session_register.py
new file mode 100644
index 00000000..2e8cb45b
--- /dev/null
+++ b/src/ccbot/session_register.py
@@ -0,0 +1,150 @@
+"""Session registration helpers for tmux-backed Claude Code / Codex sessions.
+
+Provides a CLI-friendly way for hook subprocesses to register tmux window
+metadata in ``session_map.json`` without importing the main config module.
+"""
+
+from __future__ import annotations
+
+import argparse
+import fcntl
+import json
+import logging
+import os
+import re
+import sys
+
+from .runtimes import RUNTIME_CLAUDE, SUPPORTED_RUNTIMES
+from .utils import atomic_write_json, ccbot_dir
+
+logger = logging.getLogger(__name__)
+
+_WINDOW_ID_RE = re.compile(r"^@\d+$")
+
+
+def build_session_map_key(window_id: str, tmux_session_name: str | None = None) -> str:
+ """Build the persisted tmux session_map key for a window."""
+ session_name = tmux_session_name or os.getenv("TMUX_SESSION_NAME", "ccbot")
+ return f"{session_name}:{window_id}"
+
+
+def register_session(
+ *,
+ window_id: str,
+ session_id: str,
+ cwd: str,
+ window_name: str = "",
+ runtime: str = RUNTIME_CLAUDE,
+ transcript_path: str = "",
+ tmux_session_name: str | None = None,
+) -> bool:
+ """Register or update a tmux window -> Claude Code / Codex session mapping."""
+ if not _WINDOW_ID_RE.match(window_id):
+ logger.warning("Invalid tmux window_id: %s", window_id)
+ return False
+ if not session_id.strip():
+ logger.warning("Empty session_id, ignoring session registration")
+ return False
+ if cwd and not os.path.isabs(cwd):
+ logger.warning("cwd is not absolute: %s", cwd)
+ return False
+ if transcript_path and not os.path.isabs(transcript_path):
+ logger.warning("transcript_path is not absolute: %s", transcript_path)
+ return False
+ if runtime not in SUPPORTED_RUNTIMES:
+ logger.warning("Unsupported runtime: %s", runtime)
+ return False
+
+ map_file = ccbot_dir() / "session_map.json"
+ map_file.parent.mkdir(parents=True, exist_ok=True)
+ lock_path = map_file.with_suffix(".lock")
+ map_key = build_session_map_key(window_id, tmux_session_name)
+
+ try:
+ with open(lock_path, "w", encoding="utf-8") as lock_f:
+ fcntl.flock(lock_f, fcntl.LOCK_EX)
+ try:
+ session_map: dict[str, dict[str, str]] = {}
+ if map_file.exists():
+ try:
+ session_map = json.loads(map_file.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ logger.warning(
+ "Failed to read existing session_map, starting fresh"
+ )
+
+ session_entry = {
+ "session_id": session_id,
+ "cwd": cwd,
+ "window_name": window_name,
+ "runtime": runtime,
+ }
+ if transcript_path:
+ session_entry["transcript_path"] = transcript_path
+
+ session_map[map_key] = session_entry
+
+ old_key = build_session_map_key(window_name, tmux_session_name)
+ if old_key != map_key and old_key in session_map:
+ del session_map[old_key]
+ logger.info("Removed old-format session_map key: %s", old_key)
+
+ atomic_write_json(map_file, session_map)
+ logger.info(
+ "Updated session_map: %s -> session_id=%s runtime=%s cwd=%s",
+ map_key,
+ session_id,
+ runtime,
+ cwd,
+ )
+ return True
+ finally:
+ fcntl.flock(lock_f, fcntl.LOCK_UN)
+ except OSError as e:
+ logger.error("Failed to write session_map: %s", e)
+ return False
+
+
+def session_register_main() -> None:
+ """CLI entry point for registering a Claude Code / Codex session."""
+ logging.basicConfig(
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ level=logging.DEBUG,
+ stream=sys.stderr,
+ )
+
+ parser = argparse.ArgumentParser(
+ prog="ccbot session-register",
+ description="Register a tmux window -> Claude Code / Codex session mapping",
+ )
+ parser.add_argument("--window-id", required=True, help="tmux window ID, e.g. @12")
+ parser.add_argument("--session-id", required=True, help="Runtime session ID")
+ parser.add_argument("--cwd", required=True, help="Absolute working directory")
+ parser.add_argument("--window-name", default="", help="Display name for the window")
+ parser.add_argument(
+ "--runtime",
+ default=RUNTIME_CLAUDE,
+ help=f"Runtime name / tool family ({', '.join(sorted(SUPPORTED_RUNTIMES))})",
+ )
+ parser.add_argument(
+ "--transcript-path",
+ default="",
+ help="Absolute path to the session transcript file",
+ )
+ parser.add_argument(
+ "--tmux-session-name",
+ default=os.getenv("TMUX_SESSION_NAME", "ccbot"),
+ help="tmux session name used for the session_map key",
+ )
+ args = parser.parse_args(sys.argv[2:])
+
+ ok = register_session(
+ window_id=args.window_id,
+ session_id=args.session_id,
+ cwd=args.cwd,
+ window_name=args.window_name,
+ runtime=args.runtime,
+ transcript_path=args.transcript_path,
+ tmux_session_name=args.tmux_session_name,
+ )
+ sys.exit(0 if ok else 1)
diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py
index 1afefed0..dbad2816 100644
--- a/src/ccbot/terminal_parser.py
+++ b/src/ccbot/terminal_parser.py
@@ -107,12 +107,14 @@ class UIPattern:
top=(
re.compile(r"^\s*Settings:.*tab to cycle"),
re.compile(r"^\s*Select model"),
+ re.compile(r"^\s*Select Reasoning Level\b"),
),
bottom=(
re.compile(r"Esc to cancel"),
re.compile(r"Esc to exit"),
re.compile(r"Enter to confirm"),
re.compile(r"^\s*Type to filter"),
+ re.compile(r"^\s*Press enter to confirm or esc to go back"),
),
),
]
diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py
index f05b4f3a..5ff5a655 100644
--- a/src/ccbot/tmux_manager.py
+++ b/src/ccbot/tmux_manager.py
@@ -1,7 +1,7 @@
"""Tmux session/window management via libtmux.
Wraps libtmux to provide async-friendly operations on a single tmux session:
- - list_windows / find_window_by_name: discover Claude Code windows.
+ - list_windows / find_window_by_name: discover runtime windows.
- capture_pane: read terminal content (plain or with ANSI colors).
- send_keys: forward user input or control keys to a window.
- create_window / kill_window: lifecycle management.
@@ -15,12 +15,14 @@
import asyncio
import logging
+import shlex
from dataclasses import dataclass
from pathlib import Path
import libtmux
from .config import SENSITIVE_ENV_VARS, config
+from .runtimes import RUNTIME_CLAUDE, RUNTIME_CODEX
logger = logging.getLogger(__name__)
@@ -36,7 +38,7 @@ class TmuxWindow:
class TmuxManager:
- """Manages tmux windows for Claude Code sessions."""
+ """Manages tmux windows for runtime sessions."""
def __init__(self, session_name: str | None = None):
"""Initialize tmux manager.
@@ -364,20 +366,43 @@ def _sync_kill() -> bool:
return await asyncio.to_thread(_sync_kill)
+ def _build_runtime_command(
+ self,
+ *,
+ window_id: str,
+ window_name: str,
+ work_dir: Path,
+ resume_session_id: str | None,
+ ) -> str:
+ """Build the shell command used to start the configured runtime."""
+ if config.runtime == RUNTIME_CLAUDE:
+ cmd = config.claude_command
+ if resume_session_id:
+ cmd = f"{cmd} --resume {shlex.quote(resume_session_id)}"
+ return cmd
+
+ if config.runtime == RUNTIME_CODEX:
+ cmd = config.codex_command
+ if resume_session_id:
+ cmd = f"{cmd} resume {shlex.quote(resume_session_id)}"
+ return f"CCBOT_RUNTIME={shlex.quote(config.runtime)} {cmd}"
+
+ raise ValueError(f"Unsupported runtime: {config.runtime}")
+
async def create_window(
self,
work_dir: str,
window_name: str | None = None,
- start_claude: bool = True,
+ start_runtime: bool = True,
resume_session_id: str | None = None,
) -> tuple[bool, str, str, str]:
- """Create a new tmux window and optionally start Claude Code.
+ """Create a new tmux window and optionally start the configured runtime.
Args:
work_dir: Working directory for the new window
window_name: Optional window name (defaults to directory name)
- start_claude: Whether to start claude command
- resume_session_id: If set, append --resume to claude command
+ start_runtime: Whether to start the configured runtime command
+ resume_session_id: Optional resume identifier forwarded to the runtime
Returns:
Tuple of (success, message, window_name, window_id)
@@ -414,13 +439,16 @@ def _create_and_start() -> tuple[bool, str, str, str]:
# Prevent Claude Code from overriding window name
window.set_window_option("allow-rename", "off")
- # Start Claude Code if requested
- if start_claude:
+ # Start the configured runtime if requested
+ if start_runtime:
pane = window.active_pane
if pane:
- cmd = config.claude_command
- if resume_session_id:
- cmd = f"{cmd} --resume {resume_session_id}"
+ cmd = self._build_runtime_command(
+ window_id=wid,
+ window_name=final_window_name,
+ work_dir=path,
+ resume_session_id=resume_session_id,
+ )
pane.send_keys(cmd, enter=True)
logger.info(
diff --git a/src/ccbot/transcript_parser.py b/src/ccbot/transcript_parser.py
index fa0bbf69..05d0b5f5 100644
--- a/src/ccbot/transcript_parser.py
+++ b/src/ccbot/transcript_parser.py
@@ -1,7 +1,8 @@
-"""JSONL transcript parser for Claude Code session files.
+"""JSONL transcript parser for runtime session files.
-Parses Claude Code session JSONL files and extracts structured messages.
-Handles: text, thinking, tool_use, tool_result, local_command, and user messages.
+Parses Claude Code and Codex session JSONL files and extracts structured
+messages. Handles: text, thinking, tool_use, tool_result, local_command, and
+user messages.
Tool pairing: tool_use blocks in assistant messages are matched with
tool_result blocks in subsequent user messages via tool_use_id.
@@ -31,6 +32,31 @@ class ParsedMessage:
tool_name: str | None = None # For tool_use messages
+@dataclass(frozen=True)
+class CodexPromptOption:
+ """A single option from Codex request_user_input."""
+
+ label: str
+ description: str = ""
+
+
+@dataclass(frozen=True)
+class CodexPromptQuestion:
+ """A single Codex request_user_input question."""
+
+ header: str
+ question_id: str
+ question: str
+ options: tuple[CodexPromptOption, ...]
+
+
+@dataclass(frozen=True)
+class CodexPromptPayload:
+ """Structured prompt payload for Codex request_user_input."""
+
+ questions: tuple[CodexPromptQuestion, ...]
+
+
@dataclass
class ParsedEntry:
"""A single parsed message entry ready for display."""
@@ -48,6 +74,7 @@ class ParsedEntry:
image_data: list[tuple[str, bytes]] | None = (
None # For tool_result entries with images: (media_type, raw_bytes)
)
+ interactive_prompt: CodexPromptPayload | None = None
@dataclass
@@ -57,15 +84,15 @@ class PendingToolInfo:
summary: str # Formatted tool summary (e.g. "**Read**(file.py)")
tool_name: str # Tool name (e.g. "Read", "Edit")
input_data: Any = None # Tool input parameters (for Edit to generate diff)
+ interactive_prompt: CodexPromptPayload | None = None
class TranscriptParser:
- """Parser for Claude Code JSONL session files.
+ """Parser for runtime JSONL session files.
Expected JSONL entry structure:
- - type: "user" | "assistant" | "summary" | "file-history-snapshot" | ...
- - message.content: list of blocks (text, tool_use, tool_result, thinking)
- - sessionId, cwd, timestamp, uuid: metadata fields
+ - Claude: type "user" | "assistant" | "summary" | ...
+ - Codex: type "session_meta" | "response_item" | "event_msg" | ...
Tool pairing model: tool_use blocks appear in assistant messages,
matching tool_result blocks appear in the next user message (keyed by tool_use_id).
@@ -102,12 +129,25 @@ def get_message_type(data: dict) -> str | None:
Returns:
Message type: "user", "assistant", "file-history-snapshot", etc.
"""
+ if data.get("type") == "response_item":
+ payload = data.get("payload", {})
+ if isinstance(payload, dict) and payload.get("type") == "message":
+ return str(payload.get("role", "assistant"))
+ if data.get("type") == "message":
+ return str(data.get("role", "assistant"))
return data.get("type")
@staticmethod
def is_user_message(data: dict) -> bool:
"""Check if this is a user message."""
- return data.get("type") == "user"
+ return data.get("type") == "user" or (
+ data.get("type") == "message" and data.get("role") == "user"
+ ) or (
+ data.get("type") == "response_item"
+ and isinstance(data.get("payload"), dict)
+ and data["payload"].get("type") == "message"
+ and data["payload"].get("role") == "user"
+ )
@staticmethod
def extract_text_only(content_list: list[Any]) -> str:
@@ -204,7 +244,7 @@ def format_tool_use_summary(cls, name: str, input_data: dict | Any) -> str:
summary = f"{len(todos)} item(s)"
elif name == "TodoRead":
summary = ""
- elif name == "AskUserQuestion":
+ elif name in ("AskUserQuestion", "request_user_input"):
questions = input_data.get("questions", [])
if isinstance(questions, list) and questions:
q = questions[0]
@@ -272,6 +312,74 @@ def extract_tool_result_images(
logger.debug("Failed to decode base64 image in tool_result")
return images if images else None
+ @staticmethod
+ def _decode_tool_input(raw_input: Any) -> Any:
+ """Decode a tool input payload when it arrives as a JSON string."""
+ if not isinstance(raw_input, str):
+ return raw_input
+ raw_input = raw_input.strip()
+ if not raw_input:
+ return {}
+ try:
+ return json.loads(raw_input)
+ except json.JSONDecodeError:
+ return raw_input
+
+ @staticmethod
+ def _parse_codex_prompt_payload(raw_input: Any) -> CodexPromptPayload | None:
+ """Parse Codex request_user_input arguments into a structured payload."""
+ if not isinstance(raw_input, dict):
+ return None
+
+ raw_questions = raw_input.get("questions")
+ if not isinstance(raw_questions, list):
+ return None
+
+ questions: list[CodexPromptQuestion] = []
+ for index, item in enumerate(raw_questions):
+ if not isinstance(item, dict):
+ continue
+
+ question_text = str(item.get("question", "")).strip()
+ if not question_text:
+ continue
+
+ raw_options = item.get("options", [])
+ if not isinstance(raw_options, list):
+ continue
+
+ options: list[CodexPromptOption] = []
+ for opt in raw_options:
+ if not isinstance(opt, dict):
+ continue
+ label = str(opt.get("label", "")).strip()
+ if not label:
+ continue
+ options.append(
+ CodexPromptOption(
+ label=label,
+ description=str(opt.get("description", "")).strip(),
+ )
+ )
+
+ if not options:
+ continue
+
+ header = str(item.get("header", "")).strip()
+ question_id = str(item.get("id", "")).strip() or f"question_{index + 1}"
+ questions.append(
+ CodexPromptQuestion(
+ header=header,
+ question_id=question_id,
+ question=question_text,
+ options=tuple(options),
+ )
+ )
+
+ if not questions:
+ return None
+ return CodexPromptPayload(questions=tuple(questions))
+
@classmethod
def parse_message(cls, data: dict) -> ParsedMessage | None:
"""Parse a message entry from the JSONL data.
@@ -287,15 +395,38 @@ def parse_message(cls, data: dict) -> ParsedMessage | None:
if msg_type not in ("user", "assistant"):
return None
- message = data.get("message")
- if not isinstance(message, dict):
- return None
- content = message.get("content", "")
+ if data.get("type") == "response_item":
+ payload = data.get("payload")
+ if not isinstance(payload, dict) or payload.get("type") != "message":
+ return None
+ content = payload.get("content", [])
+ if not isinstance(content, list):
+ content = []
- if isinstance(content, list):
- text = cls.extract_text_only(content)
+ text_parts: list[str] = []
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ item_type = item.get("type")
+ if msg_type == "assistant" and item_type == "output_text":
+ text = str(item.get("text", "")).strip()
+ if text:
+ text_parts.append(text)
+ elif msg_type == "user" and item_type in {"input_text", "user_message"}:
+ text = str(item.get("text", "")).strip()
+ if text:
+ text_parts.append(text)
+ text = "\n".join(text_parts)
else:
- text = str(content) if content else ""
+ message = data.get("message")
+ if not isinstance(message, dict):
+ return None
+ content = message.get("content", "")
+
+ if isinstance(content, list):
+ text = cls.extract_text_only(content)
+ else:
+ text = str(content) if content else ""
text = cls._RE_ANSI_ESCAPE.sub("", text)
# Detect local command responses in user messages.
@@ -367,7 +498,7 @@ def _format_tool_result_text(cls, text: str, tool_name: str | None = None) -> st
stats = f" ⎿ Wrote {line_count} lines"
return stats
- elif tool_name == "Bash":
+ elif tool_name in {"Bash", "exec_command", "write_stdin"}:
# Bash: show output line count
if line_count > 0:
stats = f" ⎿ Output {line_count} lines"
@@ -408,6 +539,203 @@ def _format_tool_result_text(cls, text: str, tool_name: str | None = None) -> st
# Default: expandable quote without stats
return cls._format_expandable_quote(text)
+ @classmethod
+ def _build_tool_result_entry(
+ cls,
+ *,
+ timestamp: str | None,
+ tool_use_id: str | None,
+ tool_summary: str | None,
+ tool_name: str | None,
+ tool_input_data: Any,
+ result_text: str,
+ result_images: list[tuple[str, bytes]] | None = None,
+ is_error: bool = False,
+ is_interrupted: bool = False,
+ ) -> ParsedEntry | None:
+ """Build a display-ready tool_result entry."""
+ if is_interrupted:
+ entry_text = tool_summary or ""
+ if entry_text:
+ entry_text += "\n⏹ Interrupted"
+ else:
+ entry_text = "⏹ Interrupted"
+ return ParsedEntry(
+ role="assistant",
+ text=entry_text,
+ content_type="tool_result",
+ tool_use_id=tool_use_id,
+ timestamp=timestamp,
+ )
+
+ if is_error:
+ entry_text = tool_summary or "**Error**"
+ if result_text:
+ error_summary = result_text.split("\n")[0]
+ if len(error_summary) > 100:
+ error_summary = error_summary[:100] + "…"
+ entry_text += f"\n ⎿ Error: {error_summary}"
+ if "\n" in result_text:
+ entry_text += "\n" + cls._format_expandable_quote(result_text)
+ else:
+ entry_text += "\n ⎿ Error"
+ return ParsedEntry(
+ role="assistant",
+ text=entry_text,
+ content_type="tool_result",
+ tool_use_id=tool_use_id,
+ timestamp=timestamp,
+ image_data=result_images,
+ )
+
+ if tool_summary:
+ entry_text = tool_summary
+ if tool_name == "Edit" and tool_input_data and result_text:
+ old_s = tool_input_data.get("old_string", "")
+ new_s = tool_input_data.get("new_string", "")
+ if old_s and new_s:
+ diff_text = cls._format_edit_diff(old_s, new_s)
+ if diff_text:
+ added = sum(
+ 1
+ for line in diff_text.split("\n")
+ if line.startswith("+") and not line.startswith("+++")
+ )
+ removed = sum(
+ 1
+ for line in diff_text.split("\n")
+ if line.startswith("-") and not line.startswith("---")
+ )
+ stats = (
+ f" ⎿ Added {added} lines, removed {removed} lines"
+ )
+ entry_text += (
+ "\n" + stats + "\n" + cls._format_expandable_quote(diff_text)
+ )
+ elif result_text and cls.EXPANDABLE_QUOTE_START not in tool_summary:
+ entry_text += "\n" + cls._format_tool_result_text(result_text, tool_name)
+ return ParsedEntry(
+ role="assistant",
+ text=entry_text,
+ content_type="tool_result",
+ tool_use_id=tool_use_id,
+ timestamp=timestamp,
+ image_data=result_images,
+ )
+
+ if result_text or result_images:
+ return ParsedEntry(
+ role="assistant",
+ text=cls._format_tool_result_text(result_text, tool_name)
+ if result_text
+ else "",
+ content_type="tool_result",
+ tool_use_id=tool_use_id,
+ timestamp=timestamp,
+ image_data=result_images,
+ )
+
+ return None
+
+ @classmethod
+ def _parse_codex_response_item(
+ cls,
+ data: dict,
+ pending_tools: dict[str, PendingToolInfo],
+ ) -> list[ParsedEntry] | None:
+ """Parse a native Codex `response_item` entry."""
+ if data.get("type") != "response_item":
+ return None
+
+ payload = data.get("payload")
+ if not isinstance(payload, dict):
+ return []
+
+ entry_timestamp = cls.get_timestamp(data)
+ payload_type = payload.get("type")
+
+ if payload_type == "message":
+ parsed = cls.parse_message(data)
+ if not parsed or not parsed.text.strip():
+ return []
+ return [
+ ParsedEntry(
+ role=parsed.message_type,
+ text=parsed.text.strip(),
+ content_type="text",
+ timestamp=entry_timestamp,
+ )
+ ]
+
+ if payload_type in {"function_call", "custom_tool_call"}:
+ tool_id = str(payload.get("call_id", "")).strip() or None
+ name = str(payload.get("name", "unknown")).strip() or "unknown"
+ raw_input = (
+ payload.get("arguments")
+ if payload_type == "function_call"
+ else payload.get("input")
+ )
+ tool_input = cls._decode_tool_input(raw_input)
+ summary = cls.format_tool_use_summary(name, tool_input)
+ input_data = tool_input if name in ("Edit", "NotebookEdit") else None
+ interactive_prompt = (
+ cls._parse_codex_prompt_payload(tool_input)
+ if name == "request_user_input"
+ else None
+ )
+ if tool_id:
+ pending_tools[tool_id] = PendingToolInfo(
+ summary=summary,
+ tool_name=name,
+ input_data=input_data,
+ interactive_prompt=interactive_prompt,
+ )
+ return [
+ ParsedEntry(
+ role="assistant",
+ text=summary,
+ content_type="tool_use",
+ tool_use_id=tool_id,
+ timestamp=entry_timestamp,
+ tool_name=name,
+ interactive_prompt=interactive_prompt,
+ )
+ ]
+
+ if payload_type == "web_search_call":
+ action = payload.get("action", {})
+ query = ""
+ if isinstance(action, dict):
+ query = str(action.get("query", "")).strip()
+ summary = cls.format_tool_use_summary("WebSearch", {"query": query})
+ return [
+ ParsedEntry(
+ role="assistant",
+ text=summary,
+ content_type="tool_use",
+ timestamp=entry_timestamp,
+ tool_name="WebSearch",
+ )
+ ]
+
+ if payload_type in {"function_call_output", "custom_tool_call_output"}:
+ tool_id = str(payload.get("call_id", "")).strip() or None
+ result_text = str(payload.get("output", "")).strip()
+ tool_info = pending_tools.pop(tool_id, None) if tool_id else None
+ if tool_info and tool_info.tool_name == "request_user_input":
+ return []
+ entry = cls._build_tool_result_entry(
+ timestamp=entry_timestamp,
+ tool_use_id=tool_id,
+ tool_summary=tool_info.summary if tool_info else None,
+ tool_name=tool_info.tool_name if tool_info else None,
+ tool_input_data=tool_info.input_data if tool_info else None,
+ result_text=result_text,
+ )
+ return [entry] if entry is not None else []
+
+ return []
+
@classmethod
def parse_entries(
cls,
@@ -431,6 +759,7 @@ def parse_entries(
"""
result: list[ParsedEntry] = []
last_cmd_name: str | None = None
+ codex_tool_use_ids: set[str] = set()
# Pending tool_use blocks keyed by id
_carry_over = pending_tools is not None
if pending_tools is None:
@@ -439,6 +768,14 @@ def parse_entries(
pending_tools = dict(pending_tools) # don't mutate caller's dict
for data in entries:
+ codex_entries = cls._parse_codex_response_item(data, pending_tools)
+ if codex_entries is not None:
+ result.extend(codex_entries)
+ for entry in codex_entries:
+ if entry.content_type == "tool_use" and entry.tool_use_id:
+ codex_tool_use_ids.add(entry.tool_use_id)
+ continue
+
msg_type = cls.get_message_type(data)
if msg_type not in ("user", "assistant"):
continue
@@ -746,6 +1083,8 @@ def parse_entries(
remaining_pending = dict(pending_tools)
if not _carry_over:
for tool_id, tool_info in pending_tools.items():
+ if tool_id in codex_tool_use_ids:
+ continue
result.append(
ParsedEntry(
role="assistant",
diff --git a/src/ccbot/utils.py b/src/ccbot/utils.py
index c86de615..875f1d67 100644
--- a/src/ccbot/utils.py
+++ b/src/ccbot/utils.py
@@ -63,6 +63,10 @@ def read_cwd_from_jsonl(file_path: str | Path) -> str:
try:
data = json.loads(line)
cwd = data.get("cwd")
+ if not cwd and data.get("type") == "session_meta":
+ payload = data.get("payload", {})
+ if isinstance(payload, dict):
+ cwd = payload.get("cwd")
if cwd:
return cwd
except json.JSONDecodeError:
diff --git a/tests/ccbot/handlers/test_interactive_ui.py b/tests/ccbot/handlers/test_interactive_ui.py
index 8d6a98e4..07b49ed1 100644
--- a/tests/ccbot/handlers/test_interactive_ui.py
+++ b/tests/ccbot/handlers/test_interactive_ui.py
@@ -5,8 +5,15 @@
import pytest
from ccbot.handlers.interactive_ui import (
+ _codex_prompt_states,
_build_interactive_keyboard,
+ _find_codex_prompt_focus_index,
+ advance_codex_prompt_with_option,
+ arm_codex_prompt_notes_text,
+ get_codex_prompt_state,
+ handle_codex_prompt,
handle_interactive_ui,
+ submit_codex_prompt_notes_text,
)
from ccbot.handlers.callback_data import (
CB_ASK_DOWN,
@@ -18,6 +25,44 @@
CB_ASK_TAB,
CB_ASK_UP,
)
+from ccbot.transcript_parser import (
+ CodexPromptOption,
+ CodexPromptPayload,
+ CodexPromptQuestion,
+)
+
+
+def _make_codex_prompt(*, question_count: int = 2) -> CodexPromptPayload:
+ questions = [
+ CodexPromptQuestion(
+ header="Scope",
+ question_id="scope",
+ question="Which scope?",
+ options=(
+ CodexPromptOption(
+ label="Full (Recommended)",
+ description="Do the full thing",
+ ),
+ CodexPromptOption(label="Minimal", description="Do less"),
+ ),
+ )
+ ]
+ if question_count > 1:
+ questions.append(
+ CodexPromptQuestion(
+ header="Mode",
+ question_id="mode",
+ question="Which mode?",
+ options=(
+ CodexPromptOption(
+ label="Safe (Recommended)",
+ description="Conservative changes",
+ ),
+ CodexPromptOption(label="Fast", description="Move quickly"),
+ ),
+ )
+ )
+ return CodexPromptPayload(questions=tuple(questions))
@pytest.fixture
@@ -36,9 +81,11 @@ def _clear_interactive_state():
_interactive_mode.clear()
_interactive_msgs.clear()
+ _codex_prompt_states.clear()
yield
_interactive_mode.clear()
_interactive_msgs.clear()
+ _codex_prompt_states.clear()
@pytest.mark.usefixtures("_clear_interactive_state")
@@ -109,3 +156,195 @@ def test_settings_keyboard_includes_all_nav_keys(self):
assert any(CB_ASK_RIGHT in d for d in all_cb_data if d)
assert any(CB_ASK_ESC in d for d in all_cb_data if d)
assert any(CB_ASK_ENTER in d for d in all_cb_data if d)
+
+
+@pytest.mark.usefixtures("_clear_interactive_state")
+class TestCodexPromptUI:
+ def test_find_codex_prompt_focus_index_reads_current_selection(self):
+ prompt = _make_codex_prompt(question_count=1)
+ question = prompt.questions[0]
+ pane = (
+ " Question 1/1 (1 unanswered)\n"
+ " Which scope?\n"
+ "\n"
+ " 1. Full (Recommended)\n"
+ " › 2. Minimal\n"
+ " 3. None of the above\n"
+ "\n"
+ " tab to add notes | enter to submit answer | esc to interrupt\n"
+ )
+
+ assert _find_codex_prompt_focus_index(pane, question) == 1
+
+ @pytest.mark.asyncio
+ async def test_handle_codex_prompt_sends_question_keyboard(
+ self,
+ mock_bot: AsyncMock,
+ ):
+ prompt = _make_codex_prompt()
+
+ with patch("ccbot.handlers.interactive_ui.session_manager") as mock_sm:
+ mock_sm.resolve_chat_id.return_value = 100
+
+ handled = await handle_codex_prompt(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ prompt=prompt,
+ tool_use_id="call-1",
+ thread_id=42,
+ )
+
+ assert handled is True
+ state = get_codex_prompt_state(1, 42)
+ assert state is not None
+ assert state.question_index == 0
+ mock_bot.send_message.assert_called_once()
+ kwargs = mock_bot.send_message.call_args.kwargs
+ assert kwargs["chat_id"] == 100
+ assert kwargs["message_thread_id"] == 42
+ assert "Question 1/2" in kwargs["text"]
+ buttons = [
+ button.text
+ for row in kwargs["reply_markup"].inline_keyboard
+ for button in row
+ ]
+ assert "Full (Recommended)" in buttons
+ assert "None of the above" in buttons
+ assert "Add notes" in buttons
+
+ @pytest.mark.asyncio
+ async def test_advance_codex_prompt_option_moves_to_next_question(
+ self,
+ mock_bot: AsyncMock,
+ ):
+ prompt = _make_codex_prompt()
+ mock_bot.edit_message_text = AsyncMock()
+
+ with (
+ patch("ccbot.handlers.interactive_ui.session_manager") as mock_sm,
+ patch("ccbot.handlers.interactive_ui.tmux_manager") as mock_tmux,
+ ):
+ mock_sm.resolve_chat_id.return_value = 100
+ mock_tmux.send_keys = AsyncMock(return_value=True)
+ mock_tmux.capture_pane = AsyncMock(
+ return_value=(
+ " Question 1/2 (2 unanswered)\n"
+ " Which scope?\n"
+ "\n"
+ " › 1. Full (Recommended)\n"
+ " 2. Minimal\n"
+ " 3. None of the above\n"
+ "\n"
+ " tab to add notes | enter to submit answer | esc to interrupt\n"
+ )
+ )
+
+ await handle_codex_prompt(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ prompt=prompt,
+ tool_use_id="call-2",
+ thread_id=42,
+ )
+ success, message = await advance_codex_prompt_with_option(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ thread_id=42,
+ question_index=0,
+ option_index=1,
+ )
+
+ assert success is True
+ assert message == "Saved"
+ state = get_codex_prompt_state(1, 42)
+ assert state is not None
+ assert state.question_index == 1
+ assert state.answers["scope"] == "Minimal"
+ sent_keys = [call.args[1] for call in mock_tmux.send_keys.await_args_list]
+ assert sent_keys == ["Down", "Enter"]
+ mock_bot.edit_message_text.assert_called()
+
+ @pytest.mark.asyncio
+ async def test_arm_codex_prompt_notes_updates_message(
+ self,
+ mock_bot: AsyncMock,
+ ):
+ prompt = _make_codex_prompt()
+ mock_bot.edit_message_text = AsyncMock()
+
+ with patch("ccbot.handlers.interactive_ui.session_manager") as mock_sm:
+ mock_sm.resolve_chat_id.return_value = 100
+ await handle_codex_prompt(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ prompt=prompt,
+ tool_use_id="call-3",
+ thread_id=42,
+ )
+ success, message = await arm_codex_prompt_notes_text(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ thread_id=42,
+ question_index=0,
+ )
+
+ assert success is True
+ assert message == "Send notes"
+ state = get_codex_prompt_state(1, 42)
+ assert state is not None
+ assert state.awaiting_notes_text is True
+ assert (
+ "Send your next text message" in mock_bot.edit_message_text.call_args.kwargs["text"]
+ )
+
+ @pytest.mark.asyncio
+ async def test_submit_codex_prompt_notes_saves_note_without_submitting(
+ self,
+ mock_bot: AsyncMock,
+ ):
+ prompt = _make_codex_prompt(question_count=1)
+ mock_bot.edit_message_text = AsyncMock()
+
+ with (
+ patch("ccbot.handlers.interactive_ui.session_manager") as mock_sm,
+ patch("ccbot.handlers.interactive_ui.tmux_manager") as mock_tmux,
+ ):
+ mock_sm.resolve_chat_id.return_value = 100
+ mock_tmux.send_keys = AsyncMock(return_value=True)
+ await handle_codex_prompt(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ prompt=prompt,
+ tool_use_id="call-4",
+ thread_id=42,
+ )
+ success, message = await arm_codex_prompt_notes_text(
+ mock_bot,
+ user_id=1,
+ window_id="@5",
+ thread_id=42,
+ question_index=0,
+ )
+ assert success is True
+
+ success, message = await submit_codex_prompt_notes_text(
+ mock_bot,
+ user_id=1,
+ thread_id=42,
+ text="Inspect deployment scripts",
+ )
+
+ assert success is True
+ assert message == "Notes saved"
+ state = get_codex_prompt_state(1, 42)
+ assert state is not None
+ assert state.notes["scope"] == "Inspect deployment scripts"
+ assert state.awaiting_notes_text is False
+ mock_tmux.send_keys.assert_not_called()
+ assert "Notes: Inspect deployment scripts" in mock_bot.edit_message_text.call_args.kwargs["text"]
diff --git a/tests/ccbot/handlers/test_status_polling.py b/tests/ccbot/handlers/test_status_polling.py
index 9c0f04f7..a0a88f72 100644
--- a/tests/ccbot/handlers/test_status_polling.py
+++ b/tests/ccbot/handlers/test_status_polling.py
@@ -24,13 +24,19 @@ def mock_bot():
@pytest.fixture
def _clear_interactive_state():
"""Ensure interactive state is clean before and after each test."""
- from ccbot.handlers.interactive_ui import _interactive_mode, _interactive_msgs
+ from ccbot.handlers.interactive_ui import (
+ _codex_prompt_states,
+ _interactive_mode,
+ _interactive_msgs,
+ )
_interactive_mode.clear()
_interactive_msgs.clear()
+ _codex_prompt_states.clear()
yield
_interactive_mode.clear()
_interactive_msgs.clear()
+ _codex_prompt_states.clear()
@pytest.mark.usefixtures("_clear_interactive_state")
@@ -102,6 +108,74 @@ async def test_normal_pane_no_interactive_ui(self, mock_bot: AsyncMock):
mock_handle_ui.assert_not_called()
+ @pytest.mark.asyncio
+ async def test_active_codex_prompt_skips_status_updates(self, mock_bot: AsyncMock):
+ """While a Telegram-native Codex prompt is active, polling stays quiet."""
+ window_id = "@5"
+ mock_window = MagicMock()
+ mock_window.window_id = window_id
+ pane = "normal output\n✻ Thinking\n──────────────────────────────────────\n❯\n"
+
+ with (
+ patch("ccbot.handlers.status_polling.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.handlers.status_polling.has_codex_prompt",
+ return_value=True,
+ ),
+ patch(
+ "ccbot.handlers.status_polling.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ patch(
+ "ccbot.handlers.status_polling.enqueue_status_update",
+ new_callable=AsyncMock,
+ ) as mock_enqueue_status,
+ ):
+ mock_tmux.find_window_by_id = AsyncMock(return_value=mock_window)
+ mock_tmux.capture_pane = AsyncMock(return_value=pane)
+
+ await update_status_message(
+ mock_bot, user_id=1, window_id=window_id, thread_id=42
+ )
+
+ mock_handle_ui.assert_not_called()
+ mock_enqueue_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_existing_interactive_ui_is_refreshed(self, mock_bot: AsyncMock):
+ window_id = "@5"
+ mock_window = MagicMock()
+ mock_window.window_id = window_id
+ pane = (
+ " Select Reasoning Level for gpt-5.4\n"
+ "\n"
+ " 1. Low\n"
+ "› 2. Extra high\n"
+ "\n"
+ " Press enter to confirm or esc to go back\n"
+ )
+
+ with (
+ patch("ccbot.handlers.status_polling.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.handlers.status_polling.get_interactive_window",
+ return_value=window_id,
+ ),
+ patch(
+ "ccbot.handlers.status_polling.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ ):
+ mock_tmux.find_window_by_id = AsyncMock(return_value=mock_window)
+ mock_tmux.capture_pane = AsyncMock(return_value=pane)
+ mock_handle_ui.return_value = True
+
+ await update_status_message(
+ mock_bot, user_id=1, window_id=window_id, thread_id=42
+ )
+
+ mock_handle_ui.assert_called_once_with(mock_bot, 1, window_id, 42)
+
@pytest.mark.asyncio
async def test_settings_ui_end_to_end_sends_telegram_keyboard(
self, mock_bot: AsyncMock, sample_pane_settings: str
diff --git a/tests/ccbot/test_bot_codex_prompt.py b/tests/ccbot/test_bot_codex_prompt.py
new file mode 100644
index 00000000..54ed4526
--- /dev/null
+++ b/tests/ccbot/test_bot_codex_prompt.py
@@ -0,0 +1,191 @@
+"""Tests for Codex request_user_input handling in the Telegram bot."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from ccbot.bot import callback_handler, handle_new_message, text_handler
+from ccbot.handlers.callback_data import CB_CODEX_PROMPT_OPTION
+from ccbot.session_monitor import NewMessage
+from ccbot.transcript_parser import (
+ CodexPromptOption,
+ CodexPromptPayload,
+ CodexPromptQuestion,
+)
+
+
+def _make_prompt() -> CodexPromptPayload:
+ return CodexPromptPayload(
+ questions=(
+ CodexPromptQuestion(
+ header="Scope",
+ question_id="scope",
+ question="Which scope?",
+ options=(
+ CodexPromptOption(
+ label="Full (Recommended)",
+ description="Do the full thing",
+ ),
+ CodexPromptOption(label="Minimal", description="Do less"),
+ ),
+ ),
+ )
+ )
+
+
+def _make_text_update(text: str, thread_id: int = 42) -> MagicMock:
+ update = MagicMock()
+ update.effective_user = MagicMock()
+ update.effective_user.id = 1
+ update.effective_chat = MagicMock()
+ update.effective_chat.type = "supergroup"
+ update.effective_chat.id = 100
+ update.message = MagicMock()
+ update.message.text = text
+ update.message.message_thread_id = thread_id
+ update.message.chat = MagicMock()
+ update.message.chat.send_action = AsyncMock()
+ return update
+
+
+def _make_callback_update(data: str, thread_id: int = 42) -> MagicMock:
+ update = MagicMock()
+ update.effective_user = MagicMock()
+ update.effective_user.id = 1
+ update.effective_chat = MagicMock()
+ update.effective_chat.type = "supergroup"
+ update.effective_chat.id = 100
+ update.message = None
+ update.callback_query = MagicMock()
+ update.callback_query.data = data
+ update.callback_query.answer = AsyncMock()
+ update.callback_query.message = MagicMock()
+ update.callback_query.message.message_thread_id = thread_id
+ return update
+
+
+def _make_context() -> MagicMock:
+ context = MagicMock()
+ context.bot = AsyncMock()
+ context.user_data = {}
+ return context
+
+
+class TestCodexPromptBotFlow:
+ @pytest.mark.asyncio
+ async def test_text_handler_uses_pending_notes_submission(self):
+ update = _make_text_update("Inspect deployment scripts")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot.has_codex_prompt", return_value=True),
+ patch("ccbot.bot.is_waiting_for_codex_notes_text", return_value=True),
+ patch(
+ "ccbot.bot.submit_codex_prompt_notes_text",
+ new_callable=AsyncMock,
+ ) as mock_submit,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ patch("ccbot.bot.session_manager") as mock_sm,
+ ):
+ mock_submit.return_value = (True, "Saved")
+
+ await text_handler(update, context)
+
+ mock_submit.assert_called_once_with(
+ context.bot, 1, 42, "Inspect deployment scripts"
+ )
+ mock_reply.assert_not_called()
+ mock_sm.get_window_for_thread.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_text_handler_blocks_plain_text_while_prompt_pending(self):
+ update = _make_text_update("hello")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot.has_codex_prompt", return_value=True),
+ patch("ccbot.bot.is_waiting_for_codex_notes_text", return_value=False),
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ patch("ccbot.bot.session_manager") as mock_sm,
+ ):
+ await text_handler(update, context)
+
+ mock_reply.assert_called_once()
+ assert "pending Codex question" in mock_reply.call_args.args[1]
+ assert "Add notes" in mock_reply.call_args.args[1]
+ mock_sm.get_window_for_thread.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_callback_handler_routes_codex_option(self):
+ update = _make_callback_update(f"{CB_CODEX_PROMPT_OPTION}0:1:@5")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch(
+ "ccbot.bot.advance_codex_prompt_with_option",
+ new_callable=AsyncMock,
+ ) as mock_advance,
+ patch("ccbot.bot.session_manager") as mock_sm,
+ ):
+ mock_advance.return_value = (True, "Saved")
+
+ await callback_handler(update, context)
+
+ mock_sm.set_group_chat_id.assert_called_once_with(1, 42, 100)
+ mock_advance.assert_called_once_with(
+ context.bot,
+ 1,
+ "@5",
+ 42,
+ 0,
+ 1,
+ )
+ update.callback_query.answer.assert_called_once_with("Saved", show_alert=False)
+
+ @pytest.mark.asyncio
+ async def test_handle_new_message_renders_codex_prompt_instead_of_queueing(self):
+ bot = AsyncMock()
+ prompt = _make_prompt()
+ msg = NewMessage(
+ session_id="session-1",
+ text="**request_user_input**(Which scope?)",
+ is_complete=False,
+ content_type="tool_use",
+ tool_use_id="call-1",
+ tool_name="request_user_input",
+ interactive_prompt=prompt,
+ )
+
+ with (
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch(
+ "ccbot.bot.handle_codex_prompt",
+ new_callable=AsyncMock,
+ ) as mock_handle_prompt,
+ patch("ccbot.bot.get_message_queue", return_value=None),
+ patch(
+ "ccbot.bot.enqueue_content_message",
+ new_callable=AsyncMock,
+ ) as mock_enqueue,
+ ):
+ mock_sm.find_users_for_session = AsyncMock(return_value=[(1, "@5", 42)])
+ mock_sm.resolve_session_for_window = AsyncMock(
+ return_value=SimpleNamespace(file_path=None)
+ )
+ mock_handle_prompt.return_value = True
+
+ await handle_new_message(msg, bot)
+
+ mock_handle_prompt.assert_called_once_with(
+ bot,
+ 1,
+ "@5",
+ prompt,
+ "call-1",
+ 42,
+ )
+ mock_enqueue.assert_not_called()
diff --git a/tests/ccbot/test_config.py b/tests/ccbot/test_config.py
index 95cf35f9..1230d37a 100644
--- a/tests/ccbot/test_config.py
+++ b/tests/ccbot/test_config.py
@@ -5,12 +5,14 @@
import pytest
from ccbot.config import Config
+from ccbot.runtimes import RUNTIME_CLAUDE, RUNTIME_CODEX
@pytest.fixture
def _base_env(monkeypatch, tmp_path):
# chdir to tmp_path so load_dotenv won't find the real .env in repo root
monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test:token")
monkeypatch.setenv("ALLOWED_USERS", "12345")
monkeypatch.setenv("CCBOT_DIR", str(tmp_path))
@@ -33,6 +35,45 @@ def test_custom_monitor_poll_interval(self, monkeypatch):
cfg = Config()
assert cfg.monitor_poll_interval == 5.0
+ def test_runtime_defaults_to_claude(self):
+ cfg = Config()
+ assert cfg.runtime == RUNTIME_CLAUDE
+
+ def test_codex_runtime_config(self, monkeypatch, tmp_path):
+ monkeypatch.setenv("CCBOT_RUNTIME", RUNTIME_CODEX)
+ monkeypatch.setenv("CODEX_COMMAND", "codex --no-alt-screen")
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex"))
+ cfg = Config()
+ assert cfg.runtime == RUNTIME_CODEX
+ assert cfg.codex_command == (
+ "codex --no-alt-screen --enable codex_hooks "
+ "--enable default_mode_request_user_input"
+ )
+ assert cfg.codex_home == tmp_path / ".codex"
+ assert cfg.codex_sessions_path == tmp_path / ".codex" / "sessions"
+
+ def test_codex_command_keeps_existing_hook_flag(self, monkeypatch):
+ monkeypatch.setenv("CCBOT_RUNTIME", RUNTIME_CODEX)
+ monkeypatch.setenv(
+ "CODEX_COMMAND",
+ "codex --no-alt-screen --enable codex_hooks "
+ "--enable default_mode_request_user_input",
+ )
+ cfg = Config()
+ assert cfg.codex_command == (
+ "codex --no-alt-screen --enable codex_hooks "
+ "--enable default_mode_request_user_input"
+ )
+
+ def test_codex_command_adds_only_missing_feature(self, monkeypatch):
+ monkeypatch.setenv("CCBOT_RUNTIME", RUNTIME_CODEX)
+ monkeypatch.setenv("CODEX_COMMAND", "codex --no-alt-screen --enable codex_hooks")
+ cfg = Config()
+ assert cfg.codex_command == (
+ "codex --no-alt-screen --enable codex_hooks "
+ "--enable default_mode_request_user_input"
+ )
+
def test_is_user_allowed_true(self):
cfg = Config()
assert cfg.is_user_allowed(12345) is True
@@ -59,6 +100,11 @@ def test_non_numeric_allowed_users(self, monkeypatch):
with pytest.raises(ValueError, match="non-numeric"):
Config()
+ def test_invalid_runtime(self, monkeypatch):
+ monkeypatch.setenv("CCBOT_RUNTIME", "unknown")
+ with pytest.raises(ValueError, match="CCBOT_RUNTIME"):
+ Config()
+
@pytest.mark.usefixtures("_base_env")
class TestConfigClaudeProjectsPath:
diff --git a/tests/ccbot/test_forward_command.py b/tests/ccbot/test_forward_command.py
index 05f759bc..23c9fa59 100644
--- a/tests/ccbot/test_forward_command.py
+++ b/tests/ccbot/test_forward_command.py
@@ -1,9 +1,11 @@
-"""Tests for forward_command_handler — command forwarding to Claude Code."""
+"""Tests for command forwarding and runtime-specific helper commands."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from ccbot.bot import start_command
+
def _make_update(text: str, user_id: int = 1, thread_id: int = 42) -> MagicMock:
"""Build a minimal mock Update with message text in a forum topic."""
@@ -30,6 +32,64 @@ def _make_context() -> MagicMock:
class TestForwardCommand:
+ @pytest.mark.asyncio
+ async def test_plan_sends_command_to_tmux_in_codex_runtime(self):
+ update = _make_update("/plan")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.bot.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock),
+ ):
+ mock_sm.resolve_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock())
+ mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+
+ from ccbot.bot import forward_command_handler
+
+ await forward_command_handler(update, context)
+
+ mock_sm.send_to_window.assert_called_once_with("@5", "/plan")
+ mock_handle_ui.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_removed_codex_menu_command_still_forwards_manually(self):
+ update = _make_update("/memory")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.bot.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock),
+ ):
+ mock_sm.resolve_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock())
+ mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+
+ from ccbot.bot import forward_command_handler
+
+ await forward_command_handler(update, context)
+
+ mock_sm.send_to_window.assert_called_once_with("@5", "/memory")
+ mock_handle_ui.assert_not_called()
+
@pytest.mark.asyncio
async def test_model_sends_command_to_tmux(self):
"""/model → send_to_window called with "/model"."""
@@ -41,18 +101,25 @@ async def test_model_sends_command_to_tmux(self):
patch("ccbot.bot._get_thread_id", return_value=42),
patch("ccbot.bot.session_manager") as mock_sm,
patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.bot.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock),
patch("ccbot.bot.safe_reply", new_callable=AsyncMock),
):
mock_sm.resolve_window_for_thread.return_value = "@5"
mock_sm.get_display_name.return_value = "project"
mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock())
mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+ mock_handle_ui.return_value = True
from ccbot.bot import forward_command_handler
await forward_command_handler(update, context)
mock_sm.send_to_window.assert_called_once_with("@5", "/model")
+ mock_handle_ui.assert_called_once_with(context.bot, 1, "@5", 42)
@pytest.mark.asyncio
async def test_cost_sends_command_to_tmux(self):
@@ -65,6 +132,10 @@ async def test_cost_sends_command_to_tmux(self):
patch("ccbot.bot._get_thread_id", return_value=42),
patch("ccbot.bot.session_manager") as mock_sm,
patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.bot.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
patch("ccbot.bot.safe_reply", new_callable=AsyncMock),
):
mock_sm.resolve_window_for_thread.return_value = "@5"
@@ -77,6 +148,42 @@ async def test_cost_sends_command_to_tmux(self):
await forward_command_handler(update, context)
mock_sm.send_to_window.assert_called_once_with("@5", "/cost")
+ mock_handle_ui.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_model_reports_startup_blocker_when_picker_unavailable(self):
+ update = _make_update("/model")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch(
+ "ccbot.bot.handle_interactive_ui",
+ new_callable=AsyncMock,
+ ) as mock_handle_ui,
+ patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.resolve_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_window = MagicMock()
+ mock_window.window_id = "@5"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=mock_window)
+ mock_tmux.capture_pane = AsyncMock(
+ return_value="• Model selection is disabled until startup completes."
+ )
+ mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+ mock_handle_ui.return_value = False
+
+ from ccbot.bot import forward_command_handler
+
+ await forward_command_handler(update, context)
+
+ assert mock_handle_ui.await_count == 20
+ assert "Wait for startup to finish" in mock_reply.await_args_list[-1].args[1]
@pytest.mark.asyncio
async def test_clear_clears_session(self):
@@ -102,3 +209,282 @@ async def test_clear_clears_session(self):
mock_sm.send_to_window.assert_called_once_with("@5", "/clear")
mock_sm.clear_window_session.assert_called_once_with("@5")
+
+
+class TestKillCommand:
+ @pytest.mark.asyncio
+ async def test_kill_command_keeps_claude_hard_kill_behavior(self):
+ update = _make_update("/kill")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "claude"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock) as mock_clear,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.get_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@5"))
+ mock_tmux.kill_window = AsyncMock(return_value=True)
+
+ from ccbot.bot import kill_command
+
+ await kill_command(update, context)
+
+ mock_sm.send_to_window.assert_not_called()
+ mock_tmux.kill_window.assert_called_once_with("@5")
+ mock_sm.unbind_thread.assert_called_once_with(1, 42)
+ mock_sm.remove_window.assert_called_once_with("@5")
+ mock_clear.assert_called_once_with(1, 42, context.bot, context.user_data)
+ assert "Killed session" in mock_reply.await_args.args[1]
+
+ @pytest.mark.asyncio
+ async def test_kill_command_reports_failure_when_window_survives(self):
+ update = _make_update("/kill")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "claude"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock) as mock_clear,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.get_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@5"))
+ mock_tmux.kill_window = AsyncMock(return_value=False)
+
+ from ccbot.bot import kill_command
+
+ await kill_command(update, context)
+
+ mock_sm.unbind_thread.assert_not_called()
+ mock_sm.remove_window.assert_not_called()
+ mock_clear.assert_not_called()
+ assert "Failed to kill window" in mock_reply.await_args.args[1]
+
+ @pytest.mark.asyncio
+ async def test_kill_command_codex_exits_then_kills_window_and_clears_state(self):
+ update = _make_update("/kill")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock) as mock_clear,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.get_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+ mock_tmux.find_window_by_id = AsyncMock(
+ side_effect=[MagicMock(window_id="@5"), MagicMock(window_id="@5")]
+ )
+ mock_tmux.kill_window = AsyncMock(return_value=True)
+
+ from ccbot.bot import kill_command
+
+ await kill_command(update, context)
+
+ mock_sm.send_to_window.assert_called_once_with("@5", "/exit")
+ mock_tmux.kill_window.assert_called_once_with("@5")
+ mock_sm.unbind_thread.assert_called_once_with(1, 42)
+ mock_sm.remove_window.assert_called_once_with("@5")
+ mock_clear.assert_called_once_with(1, 42, context.bot, context.user_data)
+ assert "Killed session" in mock_reply.await_args.args[1]
+
+ @pytest.mark.asyncio
+ async def test_kill_command_codex_succeeds_when_window_is_gone_after_exit(self):
+ update = _make_update("/kill")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock) as mock_clear,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.get_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
+ mock_tmux.find_window_by_id = AsyncMock(
+ side_effect=[MagicMock(window_id="@5"), None]
+ )
+
+ from ccbot.bot import kill_command
+
+ await kill_command(update, context)
+
+ mock_sm.send_to_window.assert_called_once_with("@5", "/exit")
+ mock_tmux.kill_window.assert_not_called()
+ mock_sm.unbind_thread.assert_called_once_with(1, 42)
+ mock_sm.remove_window.assert_called_once_with("@5")
+ mock_clear.assert_called_once_with(1, 42, context.bot, context.user_data)
+ assert "Killed session" in mock_reply.await_args.args[1]
+
+ @pytest.mark.asyncio
+ async def test_kill_command_codex_hard_kills_when_exit_send_fails(self):
+ update = _make_update("/kill")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock) as mock_clear,
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ mock_sm.get_window_for_thread.return_value = "@5"
+ mock_sm.get_display_name.return_value = "project"
+ mock_sm.send_to_window = AsyncMock(return_value=(False, "send failed"))
+ mock_tmux.find_window_by_id = AsyncMock(
+ side_effect=[MagicMock(window_id="@5"), MagicMock(window_id="@5")]
+ )
+ mock_tmux.kill_window = AsyncMock(return_value=True)
+
+ from ccbot.bot import kill_command
+
+ await kill_command(update, context)
+
+ mock_sm.send_to_window.assert_called_once_with("@5", "/exit")
+ mock_tmux.kill_window.assert_called_once_with("@5")
+ mock_sm.unbind_thread.assert_called_once_with(1, 42)
+ mock_sm.remove_window.assert_called_once_with("@5")
+ mock_clear.assert_called_once_with(1, 42, context.bot, context.user_data)
+ assert "Killed session" in mock_reply.await_args.args[1]
+
+
+class TestRuntimeStatusCommand:
+ @pytest.mark.asyncio
+ async def test_usage_command_uses_status_for_codex_runtime(self):
+ update = _make_update("/usage")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot._get_thread_id", return_value=42),
+ patch("ccbot.bot.session_manager") as mock_sm,
+ patch("ccbot.bot.tmux_manager") as mock_tmux,
+ patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ patch(
+ "ccbot.terminal_parser.parse_usage_output",
+ return_value=None,
+ ),
+ ):
+ mock_sm.resolve_window_for_thread.return_value = "@5"
+ mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@5"))
+ mock_tmux.capture_pane = AsyncMock(return_value="Codex status output")
+ mock_tmux.send_keys = AsyncMock(return_value=True)
+
+ from ccbot.bot import usage_command
+
+ await usage_command(update, context)
+
+ sent_keys = [call.args[1] for call in mock_tmux.send_keys.await_args_list]
+ assert sent_keys == ["/status", "Escape"]
+ assert "Codex status output" in mock_reply.await_args.args[1]
+
+ def test_runtime_status_bot_command_uses_status_for_codex(self):
+ with patch("ccbot.bot.config.runtime", "codex"):
+ from ccbot.bot import _runtime_status_bot_command
+
+ assert _runtime_status_bot_command() == ("status", "Show Codex status")
+
+ def test_runtime_status_bot_command_uses_usage_for_claude(self):
+ with patch("ccbot.bot.config.runtime", "claude"):
+ from ccbot.bot import _runtime_status_bot_command
+
+ assert _runtime_status_bot_command() == (
+ "usage",
+ "Show Claude Code usage remaining",
+ )
+
+
+class TestRuntimeTextHelpers:
+ def test_runtime_monitor_title_is_codex_specific(self):
+ with patch("ccbot.bot.config.runtime", "codex"):
+ from ccbot.bot import _runtime_monitor_title
+
+ assert _runtime_monitor_title() == "Codex Monitor"
+
+ def test_runtime_forwarded_commands_are_codex_specific(self):
+ with patch("ccbot.bot.config.runtime", "codex"):
+ from ccbot.bot import _runtime_forwarded_bot_commands
+
+ commands = _runtime_forwarded_bot_commands()
+ assert commands == {
+ "clear": "↗ Clear conversation history",
+ "compact": "↗ Compact conversation context",
+ "plan": "↗ Use plan mode for the next task",
+ }
+
+ def test_runtime_escape_description_is_codex_specific(self):
+ with patch("ccbot.bot.config.runtime", "codex"):
+ from ccbot.bot import _runtime_escape_description
+
+ assert _runtime_escape_description() == "Send Escape to interrupt Codex"
+
+ def test_build_bot_commands_uses_codex_menu(self):
+ with patch("ccbot.bot.config.runtime", "codex"):
+ from ccbot.bot import _build_bot_commands
+
+ commands = {command.command: command.description for command in _build_bot_commands()}
+
+ assert "plan" in commands
+ assert "status" in commands
+ assert "usage" not in commands
+ assert "help" not in commands
+ assert "memory" not in commands
+ assert "model" not in commands
+ assert "cost" not in commands
+ assert commands["plan"] == "↗ Use plan mode for the next task"
+ assert commands["status"] == "Show Codex status"
+
+ def test_build_bot_commands_uses_claude_menu(self):
+ with patch("ccbot.bot.config.runtime", "claude"):
+ from ccbot.bot import _build_bot_commands
+
+ commands = {command.command: command.description for command in _build_bot_commands()}
+
+ assert "usage" in commands
+ assert "status" not in commands
+ assert "plan" not in commands
+ assert commands["usage"] == "Show Claude Code usage remaining"
+ assert commands["help"] == "↗ Show Claude Code help"
+ assert commands["memory"] == "↗ Edit CLAUDE.md"
+ assert commands["model"] == "↗ Switch Claude model"
+
+
+class TestStartCommand:
+ @pytest.mark.asyncio
+ async def test_start_command_uses_codex_title(self):
+ update = _make_update("/start")
+ context = _make_context()
+
+ with (
+ patch("ccbot.bot.config.runtime", "codex"),
+ patch("ccbot.bot.is_user_allowed", return_value=True),
+ patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply,
+ ):
+ await start_command(update, context)
+
+ assert "Codex Monitor" in mock_reply.await_args.args[1]
diff --git a/tests/ccbot/test_hook.py b/tests/ccbot/test_hook.py
index c69c4ff1..15542752 100644
--- a/tests/ccbot/test_hook.py
+++ b/tests/ccbot/test_hook.py
@@ -1,12 +1,13 @@
-"""Tests for Claude Code session tracking hook."""
+"""Tests for runtime session tracking hooks."""
import io
import json
+import subprocess
import sys
import pytest
-from ccbot.hook import _UUID_RE, _is_hook_installed, hook_main
+from ccbot.hook import _UUID_RE, _has_command_hook, _resolve_install_targets, hook_main
class TestUuidRegex:
@@ -36,51 +37,30 @@ def test_invalid_uuid_no_match(self, value: str) -> None:
assert _UUID_RE.match(value) is None
-class TestIsHookInstalled:
+class TestHasCommandHook:
def test_hook_present(self) -> None:
- settings = {
- "hooks": {
- "SessionStart": [
- {
- "hooks": [
- {"type": "command", "command": "ccbot hook", "timeout": 5}
- ]
- }
- ]
- }
- }
- assert _is_hook_installed(settings) is True
-
- def test_no_hooks_key(self) -> None:
- assert _is_hook_installed({}) is False
+ entries = [
+ {"hooks": [{"type": "command", "command": "ccbot hook", "timeout": 5}]}
+ ]
+ assert _has_command_hook(entries) is True
def test_different_hook_command(self) -> None:
- settings = {
- "hooks": {
- "SessionStart": [
- {"hooks": [{"type": "command", "command": "other-tool hook"}]}
- ]
- }
- }
- assert _is_hook_installed(settings) is False
+ entries = [{"hooks": [{"type": "command", "command": "other-tool hook"}]}]
+ assert _has_command_hook(entries) is False
def test_full_path_matches(self) -> None:
- settings = {
- "hooks": {
- "SessionStart": [
+ entries = [
+ {
+ "hooks": [
{
- "hooks": [
- {
- "type": "command",
- "command": "/usr/bin/ccbot hook",
- "timeout": 5,
- }
- ]
+ "type": "command",
+ "command": "/usr/bin/ccbot hook",
+ "timeout": 5,
}
]
}
- }
- assert _is_hook_installed(settings) is True
+ ]
+ assert _has_command_hook(entries) is True
class TestHookMainValidation:
@@ -144,3 +124,116 @@ def test_non_session_start_event(
},
)
assert not (tmp_path / "session_map.json").exists()
+
+ def test_codex_session_start_registers_transcript_path(
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path
+ ) -> None:
+ monkeypatch.setenv("CCBOT_DIR", str(tmp_path))
+ monkeypatch.setenv("TMUX_PANE", "%1")
+ monkeypatch.setenv("CCBOT_RUNTIME", "codex")
+
+ monkeypatch.setattr(
+ subprocess,
+ "run",
+ lambda *args, **kwargs: subprocess.CompletedProcess(
+ args=args[0],
+ returncode=0,
+ stdout="ccbot:@12:project\n",
+ stderr="",
+ ),
+ )
+
+ self._run_hook_main(
+ monkeypatch,
+ {
+ "session_id": "019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ "cwd": "/tmp/project",
+ "hook_event_name": "SessionStart",
+ "transcript_path": "/tmp/project/session.jsonl",
+ },
+ tmux_pane="%1",
+ )
+
+ session_map = json.loads((tmp_path / "session_map.json").read_text())
+ assert session_map["ccbot:@12"]["runtime"] == "codex"
+ assert session_map["ccbot:@12"]["transcript_path"] == "/tmp/project/session.jsonl"
+
+
+class TestCodexHookInstall:
+ def test_resolve_install_targets_defaults_to_both(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+ assert _resolve_install_targets(None) == ["claude", "codex"]
+
+ def test_resolve_install_targets_uses_env_runtime(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("CCBOT_RUNTIME", "codex")
+ assert _resolve_install_targets(None) == ["codex"]
+
+ def test_install_codex_writes_hooks_json(
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path
+ ) -> None:
+ codex_home = tmp_path / ".codex"
+ monkeypatch.setenv("CODEX_HOME", str(codex_home))
+ monkeypatch.setattr(sys, "argv", ["ccbot", "hook", "--install-codex"])
+
+ with pytest.raises(SystemExit) as exc:
+ hook_main()
+
+ assert exc.value.code == 0
+ hooks = json.loads((codex_home / "hooks.json").read_text())
+ assert "hooks" in hooks
+ assert "SessionStart" in hooks["hooks"]
+ assert _has_command_hook(hooks["hooks"]["SessionStart"]) is True
+
+ def test_install_with_runtime_codex_writes_hooks_json(
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path
+ ) -> None:
+ codex_home = tmp_path / ".codex"
+ monkeypatch.setenv("CODEX_HOME", str(codex_home))
+ monkeypatch.setattr(
+ sys, "argv", ["ccbot", "hook", "--install", "--run", "codex"]
+ )
+
+ with pytest.raises(SystemExit) as exc:
+ hook_main()
+
+ assert exc.value.code == 0
+ hooks = json.loads((codex_home / "hooks.json").read_text())
+ assert "hooks" in hooks
+ assert "SessionStart" in hooks["hooks"]
+
+ def test_install_codex_migrates_legacy_top_level_session_start(
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path
+ ) -> None:
+ codex_home = tmp_path / ".codex"
+ codex_home.mkdir(parents=True)
+ (codex_home / "hooks.json").write_text(
+ json.dumps(
+ {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "echo legacy",
+ }
+ ]
+ }
+ ]
+ }
+ )
+ )
+ monkeypatch.setenv("CODEX_HOME", str(codex_home))
+ monkeypatch.setattr(sys, "argv", ["ccbot", "hook", "--install-codex"])
+
+ with pytest.raises(SystemExit) as exc:
+ hook_main()
+
+ assert exc.value.code == 0
+ hooks = json.loads((codex_home / "hooks.json").read_text())
+ assert "SessionStart" not in hooks
+ assert "hooks" in hooks
+ assert _has_command_hook(hooks["hooks"]["SessionStart"]) is True
diff --git a/tests/ccbot/test_main.py b/tests/ccbot/test_main.py
new file mode 100644
index 00000000..11edd717
--- /dev/null
+++ b/tests/ccbot/test_main.py
@@ -0,0 +1,163 @@
+"""Tests for top-level ccbot CLI argument handling."""
+
+from __future__ import annotations
+
+import os
+import sys
+import types
+
+import pytest
+
+from ccbot.main import (
+ _apply_global_cli_overrides,
+ _build_codex_version_command,
+ _ensure_runtime_requirements,
+ _parse_version,
+ main,
+)
+
+
+class TestApplyGlobalCliOverrides:
+ def test_runtime_override_sets_env_and_returns_default_mode_argv(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+
+ argv = _apply_global_cli_overrides(["ccbot", "--run", "codex"])
+
+ assert argv == ["ccbot"]
+ assert os.environ["CCBOT_RUNTIME"] == "codex"
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+
+
+class TestCodexVersionChecks:
+ def test_build_codex_version_command_for_plain_codex(self) -> None:
+ assert _build_codex_version_command("codex --no-alt-screen") == [
+ "codex",
+ "--version",
+ ]
+
+ def test_build_codex_version_command_for_npx_codex(self) -> None:
+ assert _build_codex_version_command("npx codex --no-alt-screen") == [
+ "npx",
+ "codex",
+ "--version",
+ ]
+
+ def test_parse_version(self) -> None:
+ assert _parse_version("codex-cli 0.114.0") == (0, 114, 0)
+
+ def test_ensure_runtime_requirements_rejects_old_codex(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr(
+ "ccbot.main.subprocess.run",
+ lambda *args, **kwargs: types.SimpleNamespace(
+ returncode=0,
+ stdout="codex-cli 0.106.0\n",
+ stderr="",
+ ),
+ )
+
+ config = types.SimpleNamespace(
+ runtime="codex",
+ codex_command="codex --no-alt-screen",
+ )
+
+ with pytest.raises(ValueError, match="Please upgrade Codex"):
+ _ensure_runtime_requirements(config)
+
+ def test_ensure_runtime_requirements_accepts_supported_codex(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr(
+ "ccbot.main.subprocess.run",
+ lambda *args, **kwargs: types.SimpleNamespace(
+ returncode=0,
+ stdout="codex-cli 0.114.0\n",
+ stderr="",
+ ),
+ )
+
+ config = types.SimpleNamespace(
+ runtime="codex",
+ codex_command="codex --no-alt-screen",
+ )
+
+ _ensure_runtime_requirements(config)
+
+ def test_runtime_override_preserves_subcommand_argv(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+
+ argv = _apply_global_cli_overrides(
+ ["ccbot", "--run", "codex", "hook", "--install"]
+ )
+
+ assert argv == ["ccbot", "hook", "--install"]
+ assert os.environ["CCBOT_RUNTIME"] == "codex"
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+
+
+class TestMainCliOverride:
+ def test_main_uses_cli_runtime_override(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ calls: dict[str, object] = {}
+
+ class _Config:
+ allowed_users = {12345}
+ claude_projects_path = "/tmp/claude-projects"
+ codex_home = "/tmp/.codex"
+ codex_command = "codex --no-alt-screen"
+
+ @property
+ def runtime(self) -> str:
+ return os.environ.get("CCBOT_RUNTIME", "")
+
+ class _App:
+ def run_polling(self, **kwargs) -> None:
+ calls["polling_kwargs"] = kwargs
+
+ def _create_bot() -> _App:
+ calls["create_bot"] = True
+ return _App()
+
+ def _get_or_create_session() -> types.SimpleNamespace:
+ calls["tmux_ready"] = True
+ return types.SimpleNamespace(session_name="ccbot")
+
+ fake_config_module = types.ModuleType("ccbot.config")
+ fake_config_module.config = _Config()
+ fake_tmux_module = types.ModuleType("ccbot.tmux_manager")
+ fake_tmux_module.tmux_manager = types.SimpleNamespace(
+ get_or_create_session=_get_or_create_session
+ )
+ fake_bot_module = types.ModuleType("ccbot.bot")
+ fake_bot_module.create_bot = _create_bot
+
+ monkeypatch.setitem(sys.modules, "ccbot.config", fake_config_module)
+ monkeypatch.setitem(sys.modules, "ccbot.tmux_manager", fake_tmux_module)
+ monkeypatch.setitem(sys.modules, "ccbot.bot", fake_bot_module)
+ monkeypatch.setattr(sys, "argv", ["ccbot", "--run", "codex"])
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
+ monkeypatch.setattr(
+ "ccbot.main.subprocess.run",
+ lambda *args, **kwargs: types.SimpleNamespace(
+ returncode=0,
+ stdout="codex-cli 0.114.0\n",
+ stderr="",
+ ),
+ )
+
+ main()
+
+ assert os.environ["CCBOT_RUNTIME"] == "codex"
+ assert fake_config_module.config.runtime == "codex"
+ assert calls["tmux_ready"] is True
+ assert calls["create_bot"] is True
+ assert calls["polling_kwargs"] == {
+ "allowed_updates": ["message", "callback_query"]
+ }
+ monkeypatch.delenv("CCBOT_RUNTIME", raising=False)
diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py
index 022fb55a..cfe03c0c 100644
--- a/tests/ccbot/test_session.py
+++ b/tests/ccbot/test_session.py
@@ -1,8 +1,11 @@
"""Tests for SessionManager pure dict operations."""
+import json
+
import pytest
from ccbot.session import SessionManager
+from ccbot.runtimes import RUNTIME_CODEX
@pytest.fixture
@@ -107,6 +110,17 @@ def test_clear_window_session(self, mgr: SessionManager) -> None:
mgr.clear_window_session("@1")
assert mgr.get_window_state("@1").session_id == ""
+ def test_remove_window_clears_cached_state(self, mgr: SessionManager) -> None:
+ mgr.get_window_state("@1").session_id = "abc"
+ mgr.window_display_names["@1"] = "proj"
+ mgr.user_window_offsets[100] = {"@1": 42}
+
+ mgr.remove_window("@1")
+
+ assert "@1" not in mgr.window_states
+ assert "@1" not in mgr.window_display_names
+ assert 100 not in mgr.user_window_offsets
+
class TestResolveWindowForThread:
def test_none_thread_id_returns_none(self, mgr: SessionManager) -> None:
@@ -144,6 +158,67 @@ def test_bind_thread_without_name_no_display(self, mgr: SessionManager) -> None:
assert mgr.get_display_name("@1") == "@1"
+class TestCodexSessions:
+ @pytest.mark.asyncio
+ async def test_resolve_session_for_codex_window(
+ self, mgr: SessionManager, monkeypatch, tmp_path
+ ) -> None:
+ from ccbot import session as session_module
+
+ transcript_file = (
+ tmp_path
+ / "2026"
+ / "03"
+ / "11"
+ / "rollout-2026-03-11T18-27-22-019cdc6f-d3c8-7003-9730-bf3608dcaec9.jsonl"
+ )
+ transcript_file.parent.mkdir(parents=True)
+ transcript_file.write_text(
+ "\n".join(
+ [
+ json.dumps(
+ {
+ "type": "session_meta",
+ "payload": {
+ "id": "019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ "cwd": "/tmp/project",
+ },
+ }
+ ),
+ json.dumps(
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "Native Codex summary"}
+ ],
+ },
+ }
+ ),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(session_module.config, "runtime", RUNTIME_CODEX)
+ monkeypatch.setattr(session_module.config, "codex_sessions_path", tmp_path)
+
+ state = mgr.get_window_state("@4")
+ state.session_id = "019cdc6f-d3c8-7003-9730-bf3608dcaec9"
+ state.cwd = "/tmp/project"
+ state.runtime = RUNTIME_CODEX
+ state.transcript_path = str(transcript_file)
+
+ session = await mgr.resolve_session_for_window("@4")
+
+ assert session is not None
+ assert session.session_id == "019cdc6f-d3c8-7003-9730-bf3608dcaec9"
+ assert session.summary == "Native Codex summary"
+ assert session.file_path == str(transcript_file)
+
+
class TestIsWindowId:
def test_valid_ids(self, mgr: SessionManager) -> None:
assert mgr._is_window_id("@0") is True
diff --git a/tests/ccbot/test_session_monitor.py b/tests/ccbot/test_session_monitor.py
index b1274e63..1e01ace2 100644
--- a/tests/ccbot/test_session_monitor.py
+++ b/tests/ccbot/test_session_monitor.py
@@ -4,6 +4,8 @@
import pytest
+from ccbot.runtimes import RUNTIME_CODEX
+from ccbot.session import WindowState
from ccbot.monitor_state import TrackedSession
from ccbot.session_monitor import SessionMonitor
@@ -93,3 +95,57 @@ async def test_truncation_detection(self, monitor, tmp_path, make_jsonl_entry):
# Should reset offset to 0 and read the line
assert session.last_byte_offset == jsonl_file.stat().st_size
assert len(result) == 1
+
+ @pytest.mark.asyncio
+ async def test_resolve_active_sessions_for_codex(
+ self, monitor, tmp_path, monkeypatch
+ ):
+ """Codex runtime reads active transcript files from session_map state."""
+ from ccbot import session as session_module
+ from ccbot import session_monitor as monitor_module
+
+ transcript_file = (
+ tmp_path
+ / "2026"
+ / "03"
+ / "11"
+ / "rollout-2026-03-11T18-27-22-019cdc6f-d3c8-7003-9730-bf3608dcaec9.jsonl"
+ )
+ transcript_file.parent.mkdir(parents=True)
+ transcript_file.write_text(
+ json.dumps(
+ {
+ "type": "session_meta",
+ "payload": {
+ "id": "019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ "cwd": "/tmp/project",
+ },
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(monitor_module.config, "runtime", RUNTIME_CODEX)
+ monkeypatch.setattr(session_module.config, "runtime", RUNTIME_CODEX)
+ monkeypatch.setattr(session_module.config, "codex_sessions_path", tmp_path)
+ monkeypatch.setattr(
+ session_module.session_manager,
+ "window_states",
+ {
+ "@3": WindowState(
+ session_id="019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ cwd="/tmp/project",
+ runtime=RUNTIME_CODEX,
+ transcript_path=str(transcript_file),
+ )
+ },
+ )
+
+ sessions = await monitor._resolve_active_sessions(
+ {"019cdc6f-d3c8-7003-9730-bf3608dcaec9"}
+ )
+
+ assert len(sessions) == 1
+ assert sessions[0].session_id == "019cdc6f-d3c8-7003-9730-bf3608dcaec9"
+ assert sessions[0].file_path == transcript_file
diff --git a/tests/ccbot/test_session_register.py b/tests/ccbot/test_session_register.py
new file mode 100644
index 00000000..b90afeac
--- /dev/null
+++ b/tests/ccbot/test_session_register.py
@@ -0,0 +1,80 @@
+"""Tests for tmux runtime session registration helpers."""
+
+import json
+import sys
+
+import pytest
+
+from ccbot.runtimes import RUNTIME_CODEX
+from ccbot.session_register import build_session_map_key, register_session
+
+
+class TestBuildSessionMapKey:
+ def test_uses_explicit_tmux_session_name(self) -> None:
+ assert build_session_map_key("@7", "bots") == "bots:@7"
+
+
+class TestRegisterSession:
+ def test_registers_runtime_session(self, monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CCBOT_DIR", str(tmp_path))
+ monkeypatch.setenv("TMUX_SESSION_NAME", "ccbot")
+
+ ok = register_session(
+ window_id="@12",
+ session_id="019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ cwd="/tmp/project",
+ window_name="proj",
+ runtime=RUNTIME_CODEX,
+ transcript_path="/tmp/logs/rollout-2026-03-11T18-27-22-019cdc6f-d3c8-7003-9730-bf3608dcaec9.jsonl",
+ )
+
+ assert ok is True
+ session_map = json.loads((tmp_path / "session_map.json").read_text())
+ assert session_map["ccbot:@12"] == {
+ "session_id": "019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ "cwd": "/tmp/project",
+ "window_name": "proj",
+ "runtime": RUNTIME_CODEX,
+ "transcript_path": "/tmp/logs/rollout-2026-03-11T18-27-22-019cdc6f-d3c8-7003-9730-bf3608dcaec9.jsonl",
+ }
+
+ def test_rejects_invalid_window_id(self, monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CCBOT_DIR", str(tmp_path))
+
+ ok = register_session(
+ window_id="proj",
+ session_id="019cdc6f-d3c8-7003-9730-bf3608dcaec9",
+ cwd="/tmp/project",
+ )
+
+ assert ok is False
+ assert not (tmp_path / "session_map.json").exists()
+
+
+class TestSessionRegisterCli:
+ def test_cli_exits_success(self, monkeypatch, tmp_path) -> None:
+ from ccbot.session_register import session_register_main
+
+ monkeypatch.setenv("CCBOT_DIR", str(tmp_path))
+ monkeypatch.setenv("TMUX_SESSION_NAME", "ccbot")
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "ccbot",
+ "session-register",
+ "--window-id",
+ "@9",
+ "--session-id",
+ "session-9",
+ "--cwd",
+ "/tmp/project",
+ ],
+ )
+
+ with pytest.raises(SystemExit) as exc:
+ session_register_main()
+
+ assert exc.value.code == 0
+ session_map = json.loads((tmp_path / "session_map.json").read_text())
+ assert session_map["ccbot:@9"]["session_id"] == "session-9"
diff --git a/tests/ccbot/test_terminal_parser.py b/tests/ccbot/test_terminal_parser.py
index 08118430..c4a3b1be 100644
--- a/tests/ccbot/test_terminal_parser.py
+++ b/tests/ccbot/test_terminal_parser.py
@@ -128,6 +128,23 @@ def test_settings_model_picker(self, sample_pane_settings: str):
assert "Sonnet" in result.content
assert "Enter to confirm" in result.content
+ def test_codex_reasoning_picker(self):
+ pane = (
+ " Select Reasoning Level for gpt-5.4\n"
+ "\n"
+ " 1. Low Fast responses with lighter reasoning\n"
+ " 2. Medium (default) Balances speed and reasoning depth for everyday tasks\n"
+ " 3. High Greater reasoning depth for complex problems\n"
+ "› 4. Extra high (current) Extra high reasoning depth for complex problems\n"
+ "\n"
+ " Press enter to confirm or esc to go back\n"
+ )
+ result = extract_interactive_content(pane)
+ assert result is not None
+ assert result.name == "Settings"
+ assert "Select Reasoning Level" in result.content
+ assert "Press enter to confirm" in result.content
+
def test_settings_esc_to_cancel_bottom(self):
pane = (
" Settings: press tab to cycle\n"
diff --git a/tests/ccbot/test_transcript_parser.py b/tests/ccbot/test_transcript_parser.py
index 16d4c730..a7c04fa3 100644
--- a/tests/ccbot/test_transcript_parser.py
+++ b/tests/ccbot/test_transcript_parser.py
@@ -1,5 +1,7 @@
"""Tests for ccbot.transcript_parser — pure logic, no I/O."""
+import json
+
import pytest
from ccbot.transcript_parser import (
@@ -83,6 +85,11 @@ class TestFormatToolUseSummary:
{"questions": [{"question": "Continue?"}]},
"**AskUserQuestion**(Continue?)",
),
+ (
+ "request_user_input",
+ {"questions": [{"question": "Pick one"}]},
+ "**request_user_input**(Pick one)",
+ ),
("ExitPlanMode", {}, "**ExitPlanMode**"),
("Skill", {"skill": "code-review"}, "**Skill**(code-review)"),
(
@@ -103,6 +110,7 @@ class TestFormatToolUseSummary:
"TodoWrite",
"TodoRead",
"AskUserQuestion",
+ "request_user_input",
"ExitPlanMode",
"Skill",
"unknown_tool",
@@ -508,3 +516,185 @@ def test_system_tag_filtered(self, make_jsonl_entry, make_text_block):
result, pending = TranscriptParser.parse_entries(entries)
user_entries = [e for e in result if e.role == "user"]
assert len(user_entries) == 0
+
+
+class TestCodexResponseItems:
+ def test_response_item_message_entry(self):
+ entries = [
+ {
+ "type": "response_item",
+ "timestamp": "2026-03-11T12:00:00Z",
+ "payload": {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "done"}],
+ },
+ }
+ ]
+
+ result, pending = TranscriptParser.parse_entries(entries)
+
+ assert pending == {}
+ assert len(result) == 1
+ assert result[0].role == "assistant"
+ assert result[0].content_type == "text"
+ assert result[0].text == "done"
+
+ def test_function_call_and_output_pair(self):
+ data = {
+ "type": "response_item",
+ "payload": {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "continue"}],
+ },
+ }
+
+ assert TranscriptParser.is_user_message(data) is True
+
+ entries = [
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call",
+ "name": "exec_command",
+ "arguments": '{"cmd":"ls -la"}',
+ "call_id": "call-1",
+ },
+ },
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call_output",
+ "call_id": "call-1",
+ "output": "file1\nfile2",
+ },
+ },
+ ]
+
+ result, pending = TranscriptParser.parse_entries(entries)
+
+ assert pending == {}
+ assert [entry.content_type for entry in result] == ["tool_use", "tool_result"]
+ assert result[0].tool_name == "exec_command"
+ assert result[0].tool_use_id == "call-1"
+ assert "Output 2 lines" in result[1].text
+
+ def test_request_user_input_is_structured_prompt(self):
+ entries = [
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call",
+ "name": "request_user_input",
+ "arguments": json.dumps(
+ {
+ "questions": [
+ {
+ "header": "Scope",
+ "id": "scope",
+ "question": "Which scope?",
+ "options": [
+ {
+ "label": "Full (Recommended)",
+ "description": "Do the full thing",
+ },
+ {
+ "label": "Minimal",
+ "description": "Do less",
+ },
+ ],
+ }
+ ]
+ }
+ ),
+ "call_id": "call-rui-1",
+ },
+ }
+ ]
+
+ result, pending = TranscriptParser.parse_entries(entries)
+
+ assert pending["call-rui-1"].tool_name == "request_user_input"
+ assert len(result) == 1
+ entry = result[0]
+ assert entry.content_type == "tool_use"
+ assert entry.tool_name == "request_user_input"
+ assert entry.interactive_prompt is not None
+ assert entry.interactive_prompt.questions[0].question == "Which scope?"
+ assert (
+ entry.interactive_prompt.questions[0].options[0].label
+ == "Full (Recommended)"
+ )
+
+ def test_request_user_input_output_is_suppressed(self):
+ entries = [
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call",
+ "name": "request_user_input",
+ "arguments": json.dumps(
+ {
+ "questions": [
+ {
+ "header": "Scope",
+ "id": "scope",
+ "question": "Which scope?",
+ "options": [
+ {
+ "label": "Full",
+ "description": "Do the full thing",
+ },
+ {"label": "Minimal", "description": "Do less"},
+ ],
+ }
+ ]
+ }
+ ),
+ "call_id": "call-rui-2",
+ },
+ },
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "function_call_output",
+ "call_id": "call-rui-2",
+ "output": '{"answers":{"scope":{"answers":["Full"]}}}',
+ },
+ },
+ ]
+
+ result, pending = TranscriptParser.parse_entries(entries)
+
+ assert pending == {}
+ assert len(result) == 1
+ assert result[0].tool_name == "request_user_input"
+
+ def test_custom_tool_call_pair(self):
+ entries = [
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "custom_tool_call",
+ "name": "apply_patch",
+ "input": "*** Begin Patch",
+ "call_id": "call-2",
+ },
+ },
+ {
+ "type": "response_item",
+ "payload": {
+ "type": "custom_tool_call_output",
+ "call_id": "call-2",
+ "output": "Success",
+ },
+ },
+ ]
+
+ result, pending = TranscriptParser.parse_entries(entries)
+
+ assert pending == {}
+ assert [entry.content_type for entry in result] == ["tool_use", "tool_result"]
+ assert result[0].tool_name == "apply_patch"
+ assert result[1].tool_use_id == "call-2"