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"