diff --git a/README.md b/README.md index e7ee01dc..d81e212c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [中文文档](README_CN.md) [Русская документация](README_RU.md) -Control Claude Code sessions remotely via Telegram — monitor, interact, and manage AI coding sessions running in tmux. +Control AI coding sessions remotely via Telegram — monitor, interact, and manage agent sessions running in tmux. https://github.com/user-attachments/assets/15ffb38e-5eb9-4720-93b9-412e4961dc93 @@ -23,23 +23,26 @@ In fact, CCBot itself was built this way — iterating on itself through Claude ## Features -- **Topic-based sessions** — Each Telegram topic maps 1:1 to a tmux window and Claude session +- **Topic-based sessions** — Each Telegram topic maps 1:1 to a tmux window and agent 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 +- **Send messages** — Forward text to the active CLI via tmux keystrokes +- **Slash command forwarding** — Optional forwarding for unknown `/command` to the active CLI +- **Create new sessions** — Start agent 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 +- **Provider support** — Claude Code (`claude`) and Codex CLI (`codex`) +- **Session tracking** — Claude via `SessionStart` hook, Codex via rollout mapper - **Persistent state** — Thread bindings and read offsets survive restarts ## Prerequisites - **tmux** — must be installed and available in PATH -- **Claude Code** — the CLI tool (`claude`) must be installed +- **Agent CLI** — install one or both: + - Claude Code (`claude`) + - Codex CLI (`codex`) ## Installation @@ -92,7 +95,12 @@ ALLOWED_USERS=your_telegram_user_id | ----------------------- | ---------- | ------------------------------------------------ | | `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 | +| `CCBOT_PROVIDER` | `claude` | Session provider: `claude` or `codex` | +| `CCBOT_AGENT_COMMAND` | provider default (`claude`/`codex`) | Command to run in new windows | +| `CLAUDE_COMMAND` | `claude` | Backward-compatible alias for `CCBOT_AGENT_COMMAND` | +| `CCBOT_CODEX_SESSIONS_PATH` | `~/.codex/sessions` | Codex rollout root path | +| `CCBOT_FORWARD_PORTS` | _(none)_ | Comma-separated local ports to expose publicly on startup (e.g. `3000,5173`) | +| `CCBOT_FORWARD_SLASH` | `true` | Forward unknown `/command` to CLI | | `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 | @@ -104,10 +112,12 @@ There is no runtime formatter switch to MarkdownV2. > If running on a VPS where there's no interactive terminal to approve permissions, consider: > > ``` -> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions +> CCBOT_AGENT_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions > ``` -## Hook Setup (Recommended) +## Session Tracking Setup + +### Claude Provider (Recommended) Auto-install via CLI: @@ -131,6 +141,10 @@ Or manually add to `~/.claude/settings.json`: 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. +### Codex Provider + +No hook installation is required. CCBot maps tmux windows to Codex rollout files from `~/.codex/sessions` (or `CCBOT_CODEX_SESSIONS_PATH`). + ## Usage ```bash @@ -141,6 +155,40 @@ ccbot uv run ccbot ``` +### Provider Switching + +Switch provider at startup: + +```bash +# Claude Code +CCBOT_PROVIDER=claude CCBOT_AGENT_COMMAND=claude uv run ccbot + +# Codex CLI +CCBOT_PROVIDER=codex CCBOT_AGENT_COMMAND=codex uv run ccbot +``` + +You can also place these values in `~/.ccbot/.env`. + +### Port Forwarding + +Expose local dev ports with CLI flags: + +```bash +uv run ccbot --forward 3000 +``` + +Multiple ports: + +```bash +uv run ccbot --forward 3000 --forward 5173 +``` + +Behavior: + +- On startup, CCBot creates tunnel(s), sends real public URLs to all allowed users, and pins that message. +- On shutdown, CCBot stops tunnel(s) and unpins the message. +- Tunnel provider priority: `ngrok` -> `cloudflared` -> `localhost.run (ssh)`. + ### Commands **Bot commands:** @@ -150,9 +198,9 @@ uv run ccbot | `/start` | Show welcome message | | `/history` | Message history for this topic | | `/screenshot` | Capture terminal screenshot | -| `/esc` | Send Escape to interrupt Claude | +| `/esc` | Send Escape to interrupt active session | -**Claude Code commands (forwarded via tmux):** +**Claude provider commands (forwarded via tmux):** | Command | Description | | ---------- | ---------------------------- | @@ -162,7 +210,7 @@ uv run ccbot | `/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`). +For `CCBOT_PROVIDER=claude`, unknown `/command` is forwarded as-is (e.g. `/review`, `/doctor`, `/init`). ### Topic Workflow @@ -174,11 +222,11 @@ Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/revie 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 +5. A tmux window is created, agent command starts (with `--resume` when selecting a Claude session), 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. +Once a topic is bound to a session, just send text or voice messages in that topic — text gets forwarded to the active CLI via tmux keystrokes, and voice messages are transcribed then forwarded as text. **Killing a session:** @@ -206,18 +254,14 @@ I'll look into the login bug... The monitor polls session JSONL files every 2 seconds and sends notifications for: -- **Assistant responses** — Claude's text replies +- **Assistant responses** — model 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` Notifications are delivered to the topic bound to the session's window. -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 - -## Running Claude Code in tmux +## Running in tmux ### Option 1: Create via Telegram (Recommended) @@ -230,20 +274,41 @@ Formatting note: ```bash tmux attach -t ccbot tmux new-window -n myproject -c ~/Code/myproject -# Then start Claude Code in the new window +# Then start your CLI in the new window claude +# or +codex +``` + +The window must be in the `ccbot` tmux session (configurable via `TMUX_SESSION_NAME`). Claude provider uses hooks for session mapping; Codex provider uses rollout mapping. + +## Resume Existing Session + +To attach CCBot to an existing Codex session, run: + +```bash +CCBOT_PROVIDER=codex \ +CCBOT_AGENT_COMMAND='codex resume ' \ +uv run ccbot ``` -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. +Example: + +```bash +CCBOT_PROVIDER=codex \ +CCBOT_AGENT_COMMAND='codex resume 019c9eef-c5f7-7dc2-9e92-de59a1c3cd28' \ +uv run ccbot +``` ## 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/session_map.json` | Provider mappings `{tmux_session:window_id: {session_id, cwd, ...}}` | | `$CCBOT_DIR/monitor_state.json` | Monitor byte offsets per session (prevents duplicate notifications) | | `~/.claude/projects/` | Claude Code session data (read-only) | +| `~/.codex/sessions/` | Codex rollout session logs (read-only) | ## File Structure diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index bf6e8009..476010c4 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1,17 +1,17 @@ """Telegram bot handlers — the main UI layer of CCBot. Registers all command/callback/message handlers and manages the bot lifecycle. -Each Telegram topic maps 1:1 to a tmux window (Claude session). +Each Telegram topic maps 1:1 to a tmux window (agent session). Core responsibilities: - Command handlers: /start, /history, /screenshot, /esc, /kill, /unbind, - plus forwarding unknown /commands to Claude Code via tmux. + plus forwarding unknown /commands to the active agent via tmux. - Callback query handler: directory browser, history pagination, interactive UI navigation, screenshot refresh. - Topic-based routing: each named topic binds to one tmux window. Unbound topics trigger the directory browser to create a new session. - Photo handling: photos sent by user are downloaded and forwarded - to Claude Code as file paths (photo_handler). + to the active agent as file paths (photo_handler). - Voice handling: voice messages are transcribed via OpenAI API and forwarded as text (voice_handler). - Automatic cleanup: closing a topic kills the associated window @@ -58,6 +58,7 @@ ) from .config import config +from .codex_mapper import codex_session_mapper from .handlers.callback_data import ( CB_ASK_DOWN, CB_ASK_ENTER, @@ -65,6 +66,7 @@ CB_ASK_LEFT, CB_ASK_REFRESH, CB_ASK_RIGHT, + CB_ASK_SELECT, CB_ASK_SPACE, CB_ASK_TAB, CB_ASK_UP, @@ -107,6 +109,7 @@ INTERACTIVE_TOOL_NAMES, clear_interactive_mode, clear_interactive_msg, + get_interactive_choice_state, get_interactive_msg_id, get_interactive_window, handle_interactive_ui, @@ -129,6 +132,7 @@ from .markdown_v2 import convert_markdown from .handlers.response_builder import build_response_parts from .handlers.status_polling import status_poll_loop +from .port_forward import PortForwardManager, PortTunnel from .screenshot import text_to_image from .session import session_manager from .session_monitor import NewMessage, SessionMonitor @@ -140,14 +144,20 @@ logger = logging.getLogger(__name__) +# Keep startup responsive even when tmux/state files are slow. +_POST_INIT_RESOLVE_TIMEOUT_SECONDS = 8.0 + # Session monitor instance session_monitor: SessionMonitor | None = None # Status polling task _status_poll_task: asyncio.Task | None = None +_port_forward_manager: PortForwardManager | None = None +_port_forward_task: asyncio.Task[None] | None = None +_forward_pin_message_ids: dict[int, int] = {} -# Claude Code commands shown in bot menu (forwarded via tmux) -CC_COMMANDS: dict[str, str] = { +# Claude commands shown in bot menu (forwarded via tmux) +CLAUDE_COMMANDS: dict[str, str] = { "clear": "↗ Clear conversation history", "compact": "↗ Compact conversation context", "cost": "↗ Show token/cost usage", @@ -157,6 +167,12 @@ } +def _provider_menu_commands() -> dict[str, str]: + if config.provider == "claude": + return CLAUDE_COMMANDS + return {} + + def is_user_allowed(user_id: int | None) -> bool: return user_id is not None and config.is_user_allowed(user_id) @@ -174,6 +190,50 @@ def _get_thread_id(update: Update) -> int | None: return tid +async def _ensure_private_window_binding(user_id: int) -> tuple[str | None, str]: + """Ensure private chat has a stable bound window (thread_id=0).""" + private_tid = 0 + bound = session_manager.get_window_for_thread(user_id, private_tid) + if bound: + w = await tmux_manager.find_window_by_id(bound) + if w: + return bound, "" + session_manager.unbind_thread(user_id, private_tid) + + # Reuse a stable named window if it already exists. + existing = await tmux_manager.find_window_by_name(config.tmux_session_name) + if existing: + session_manager.bind_thread( + user_id, + private_tid, + existing.window_id, + window_name=existing.window_name, + ) + return existing.window_id, "" + + # Otherwise create one in current cwd. + success, message, created_wname, created_wid = await tmux_manager.create_window( + str(Path.cwd()), + window_name=config.tmux_session_name, + ) + if not success: + return None, message + + if config.provider == "codex": + await codex_session_mapper.sync_session_map() + await session_manager.wait_for_session_map_entry( + created_wid, + timeout=10.0 if config.provider == "codex" else 5.0, + ) + session_manager.bind_thread( + user_id, + private_tid, + created_wid, + window_name=created_wname, + ) + return created_wid, "" + + # --- Command handlers --- @@ -189,7 +249,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"🤖 *{config.agent_name} Monitor*\n\n" "Each topic is a session. Create a new topic to start.", ) @@ -248,7 +308,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 session without killing the window.""" user = update.effective_user if not user or not is_user_allowed(user.id): return @@ -256,29 +316,34 @@ async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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 + bind_tid = thread_id + if bind_tid is None: + chat = update.effective_chat + if chat and chat.type == "private": + bind_tid = 0 + else: + await safe_reply(update.message, "❌ This command only works in a topic.") + return - wid = session_manager.get_window_for_thread(user.id, thread_id) + wid = session_manager.get_window_for_thread(user.id, bind_tid) if not wid: await safe_reply(update.message, "❌ No session bound to this topic.") return display = session_manager.get_display_name(wid) - session_manager.unbind_thread(user.id, thread_id) - await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + session_manager.unbind_thread(user.id, bind_tid) + await clear_topic_state(user.id, bind_tid, context.bot, context.user_data) await safe_reply( update.message, f"✅ Topic unbound from window '{display}'.\n" - "The Claude session is still running in tmux.\n" + f"The {config.agent_name} session is still running in tmux.\n" "Send a message to bind to 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 agent.""" user = update.effective_user if not user or not is_user_allowed(user.id): return @@ -303,7 +368,14 @@ 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 usage stats from TUI and send to Telegram.""" + if not config.supports_usage_command: + if update.message: + await safe_reply( + update.message, + f"❌ /usage is not supported for provider '{config.provider}'.", + ) + return user = update.effective_user if not user or not is_user_allowed(user.id): return @@ -321,7 +393,7 @@ 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 + # Send /usage command to TUI await tmux_manager.send_keys(w.window_id, "/usage") # Wait for the modal to render await asyncio.sleep(2.0) @@ -486,7 +558,7 @@ async def topic_edited_handler( async def forward_command_handler( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: - """Forward any non-bot command as a slash command to the active Claude Code session.""" + """Forward non-bot slash commands to the active session.""" user = update.effective_user if not user or not is_user_allowed(user.id): return @@ -505,7 +577,18 @@ async def forward_command_handler( cmd_text = update.message.text or "" # The full text is already a slash command like "/clear" or "/compact foo" cc_slash = cmd_text.split("@")[0] # strip bot mention + if not config.forward_slash_commands: + await safe_reply( + update.message, + f"❌ Slash command forwarding is disabled for provider '{config.provider}'.", + ) + return wid = session_manager.resolve_window_for_thread(user.id, thread_id) + if wid is None and chat and chat.type == "private": + wid, err = await _ensure_private_window_binding(user.id) + if wid is None: + await safe_reply(update.message, f"❌ {err or 'Failed to create session'}") + return if not wid: await safe_reply(update.message, "❌ No session bound to this topic.") return @@ -526,7 +609,7 @@ async def forward_command_handler( await safe_reply(update.message, f"⚡ [{display}] Sent: {cc_slash}") # If /clear command was sent, clear the session association # so we can detect the new session after first message - if cc_slash.strip().lower() == "/clear": + if config.provider == "claude" and cc_slash.strip().lower() == "/clear": logger.info("Clearing session for window %s after /clear", display) session_manager.clear_window_session(wid) @@ -551,7 +634,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.", + f"⚠ Only text, photo, and voice messages are supported. " + f"Other media cannot be forwarded to {config.agent_name}.", ) @@ -561,7 +645,7 @@ async def unsupported_content_handler( async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle photos sent by the user: download and forward path to Claude Code.""" + """Handle photos sent by the user: download and forward file path.""" user = update.effective_user if not user or not is_user_allowed(user.id): if update.message: @@ -573,29 +657,37 @@ async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N chat = update.message.chat thread_id = _get_thread_id(update) + bind_tid = thread_id or 0 if chat.type in ("group", "supergroup") and thread_id is not None: session_manager.set_group_chat_id(user.id, thread_id, chat.id) - # Must be in a named topic - if thread_id is None: + is_private_chat = chat.type == "private" + + if thread_id is None and not is_private_chat: await safe_reply( update.message, "❌ Please use a named topic. Create a new topic to start a session.", ) return - wid = session_manager.get_window_for_thread(user.id, thread_id) - if wid is None: - await safe_reply( - update.message, - "❌ No session bound to this topic. Send a text message first to create one.", - ) - return + if is_private_chat: + wid, err = await _ensure_private_window_binding(user.id) + if wid is None: + await safe_reply(update.message, f"❌ {err or 'Failed to create session'}") + return + else: + wid = session_manager.resolve_window_for_thread(user.id, thread_id) + if wid is None: + await safe_reply( + update.message, + "❌ No session bound to this topic. Send a text message first to create one.", + ) + return w = await tmux_manager.find_window_by_id(wid) if not w: display = session_manager.get_display_name(wid) - session_manager.unbind_thread(user.id, thread_id) + session_manager.unbind_thread(user.id, bind_tid) await safe_reply( update.message, f"❌ Window '{display}' no longer exists. Binding removed.\n" @@ -612,7 +704,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 agent caption = update.message.caption or "" if caption: text_to_send = f"{caption}\n\n(image attached: {file_path})" @@ -628,7 +720,83 @@ 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 {config.agent_name}.") + + +async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle voice messages: transcribe via OpenAI and forward text to Claude Code.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + if update.message: + await safe_reply(update.message, "You are not authorized to use this bot.") + return + + if not update.message or not update.message.voice: + return + + if not config.openai_api_key: + await safe_reply( + update.message, + "⚠ Voice transcription requires an OpenAI API key.\n" + "Set `OPENAI_API_KEY` in your `.env` file and restart the bot.", + ) + return + + chat = update.message.chat + thread_id = _get_thread_id(update) + if chat.type in ("group", "supergroup") and thread_id is not None: + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + if thread_id is None: + await safe_reply( + update.message, + "❌ Please use a named topic. Create a new topic to start a session.", + ) + return + + wid = session_manager.get_window_for_thread(user.id, thread_id) + if wid is None: + await safe_reply( + update.message, + "❌ No session bound to this topic. Send a text message first to create one.", + ) + return + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + session_manager.unbind_thread(user.id, thread_id) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists. Binding removed.\n" + "Send a message to start a new session.", + ) + return + + # Download voice as in-memory bytes + voice_file = await update.message.voice.get_file() + ogg_data = bytes(await voice_file.download_as_bytearray()) + + # Transcribe + try: + text = await transcribe_voice(ogg_data) + except ValueError as e: + await safe_reply(update.message, f"⚠ {e}") + return + except Exception as e: + logger.error("Voice transcription failed: %s", e) + await safe_reply(update.message, f"⚠ Transcription failed: {e}") + return + + await update.message.chat.send_action(ChatAction.TYPING) + clear_status_msg_info(user.id, thread_id) + + success, message = await session_manager.send_to_window(wid, text) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + await safe_reply(update.message, f'🎤 "{text}"') async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -708,10 +876,10 @@ async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N # Active bash capture tasks: (user_id, thread_id) → asyncio.Task -_bash_capture_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} +_bash_capture_tasks: dict[tuple[int, int | None], asyncio.Task[None]] = {} -def _cancel_bash_capture(user_id: int, thread_id: int) -> None: +def _cancel_bash_capture(user_id: int, thread_id: int | None) -> None: """Cancel any running bash capture for this topic.""" key = (user_id, thread_id) task = _bash_capture_tasks.pop(key, None) @@ -722,7 +890,7 @@ def _cancel_bash_capture(user_id: int, thread_id: int) -> None: async def _capture_bash_output( bot: Bot, user_id: int, - thread_id: int, + thread_id: int | None, window_id: str, command: str, ) -> None: @@ -763,11 +931,14 @@ async def _capture_bash_output( if msg_id is None: # First capture — send a new message + send_kwargs: dict[str, int] = {} + if thread_id is not None: + send_kwargs["message_thread_id"] = thread_id sent = await send_with_fallback( bot, chat_id, output, - message_thread_id=thread_id, + **send_kwargs, # type: ignore[arg-type] ) if sent: msg_id = sent.message_id @@ -810,11 +981,12 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No return thread_id = _get_thread_id(update) + chat = update.effective_chat + is_private_chat = bool(chat and chat.type == "private") # Capture group chat_id for supergroup forum topic routing. # Required: Telegram Bot API needs group chat_id (not user_id) to send # messages with message_thread_id. Do NOT remove — see session.py docs. - chat = update.effective_chat if chat and chat.type in ("group", "supergroup"): session_manager.set_group_chat_id(user.id, thread_id, chat.id) @@ -869,15 +1041,21 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No context.user_data.pop("_pending_thread_text", None) context.user_data.pop("_selected_path", None) - # Must be in a named topic - if thread_id is None: + # Must be in a named topic unless private-chat mode is enabled. + if thread_id is None and not is_private_chat: await safe_reply( update.message, "❌ Please use a named topic. Create a new topic to start a session.", ) return - wid = session_manager.get_window_for_thread(user.id, thread_id) + if is_private_chat: + wid, err = await _ensure_private_window_binding(user.id) + if wid is None: + await safe_reply(update.message, f"❌ {err or 'Failed to create session'}") + return + else: + wid = session_manager.resolve_window_for_thread(user.id, thread_id) if wid is None: # Unbound topic — check for unbound windows first all_windows = await tmux_manager.list_windows() @@ -939,7 +1117,7 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No user.id, thread_id, ) - session_manager.unbind_thread(user.id, thread_id) + session_manager.unbind_thread(user.id, thread_id or 0) await safe_reply( update.message, f"❌ Window '{display}' no longer exists. Binding removed.\n" @@ -1574,6 +1752,54 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) await query.answer() + # Interactive UI: Numeric quick-select (1..N) + elif data.startswith(CB_ASK_SELECT): + payload = data[len(CB_ASK_SELECT) :] + parts = payload.split(":", 1) + if len(parts) != 2: + await query.answer("Invalid selection", show_alert=True) + return + index_raw, window_id = parts + try: + target_index = int(index_raw) + except ValueError: + await query.answer("Invalid selection", show_alert=True) + return + + thread_id = _get_thread_id(update) + choice_state = get_interactive_choice_state(user.id, thread_id) + if choice_state is None: + # State can be stale after restart — refresh once from terminal. + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) + choice_state = get_interactive_choice_state(user.id, thread_id) + if choice_state is None: + await query.answer("No selectable options", show_alert=True) + return + + selected_index, total_options = choice_state + if target_index < 1 or target_index > total_options: + await query.answer("Option out of range", show_alert=True) + return + + w = await tmux_manager.find_window_by_id(window_id) + if not w: + await query.answer("Window no longer exists", show_alert=True) + return + + delta = target_index - selected_index + if delta != 0: + nav_key = "Down" if delta > 0 else "Up" + for _ in range(abs(delta)): + await tmux_manager.send_keys( + w.window_id, nav_key, enter=False, literal=False + ) + await asyncio.sleep(0.05) + + await tmux_manager.send_keys(w.window_id, "Enter", enter=False, literal=False) + await asyncio.sleep(0.35) + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) + await query.answer(f"Selected {target_index}") + # Interactive UI: Down arrow elif data.startswith(CB_ASK_DOWN): window_id = data[len(CB_ASK_DOWN) :] @@ -1723,9 +1949,14 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: Routes via thread_bindings to deliver to the correct topic. """ status = "complete" if msg.is_complete else "streaming" + trace_id = msg.trace_id or f"{msg.session_id}:0-0" + logger.info( - f"handle_new_message [{status}]: session={msg.session_id}, " - f"text_len={len(msg.text)}" + "handle_new_message [%s]: trace=%s session=%s text_len=%d", + status, + trace_id, + msg.session_id, + len(msg.text), ) # Find users whose thread-bound window matches this session @@ -1735,37 +1966,54 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: logger.info(f"No active users for session {msg.session_id}") return + async def _mark_window_read_offset(user_id: int, window_id: str) -> None: + state = session_manager.get_window_state(window_id) + file_path = state.file_path + if not file_path: + # Fallback for stale state + session = await session_manager.resolve_session_for_window(window_id) + if not session or not session.file_path: + return + file_path = session.file_path + try: + file_size = Path(file_path).stat().st_size + session_manager.update_user_window_offset(user_id, window_id, file_size) + except OSError: + pass + for user_id, wid, thread_id in active_users: + queue = get_message_queue(user_id) + # 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 set_interactive_mode(user_id, wid, thread_id) # Flush pending messages (e.g. plan content) before sending interactive UI - queue = get_message_queue(user_id) if queue: await queue.join() - # Wait briefly for Claude Code to render the question UI + # Wait briefly for the agent UI to render await asyncio.sleep(0.3) handled = await handle_interactive_ui(bot, user_id, wid, thread_id) if handled: - # Update user's read offset - 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 + await _mark_window_read_offset(user_id, wid) continue # Don't send the normal tool_use message else: # UI not rendered — clear the early-set mode clear_interactive_mode(user_id, thread_id) - # Any non-interactive message means the interaction is complete — delete the UI message + # Non-interactive events can still arrive while an approval UI is visible. + # Only clear the interactive message when the UI is actually gone. if get_interactive_msg_id(user_id, thread_id): - await clear_interactive_msg(user_id, bot, thread_id) + should_clear_interactive = False + w = await tmux_manager.find_window_by_id(wid) + if not w: + should_clear_interactive = True + else: + pane_text = await tmux_manager.capture_pane(w.window_id) + if pane_text: + should_clear_interactive = not is_interactive_ui(pane_text) + if should_clear_interactive: + await clear_interactive_msg(user_id, bot, thread_id) parts = build_response_parts( msg.text, @@ -1788,24 +2036,80 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: text=msg.text, thread_id=thread_id, image_data=msg.image_data, + session_id=msg.session_id, + trace_id=trace_id, + source_line_start=msg.source_line_start, + source_line_end=msg.source_line_end, + detected_at_monotonic=msg.detected_at_monotonic, ) - # Update user's read offset to current file position - # This marks these messages as "read" for this user - 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 + await _mark_window_read_offset(user_id, wid) + + +async def _announce_forward_links(bot: Bot, tunnels: list[PortTunnel]) -> None: + """Send and pin forward links for allowed users.""" + lines = [ + "🌐 Dev port forwarding is enabled.", + "", + ] + for tunnel in tunnels: + lines.append( + f"- `localhost:{tunnel.port}` → {tunnel.public_url} ({tunnel.provider})" + ) + text = "\n".join(lines) + + for user_id in sorted(config.allowed_users): + sent = await send_with_fallback(bot, user_id, text) + if sent is None: + logger.warning( + "Failed to announce forward links to user %d", user_id + ) + continue + try: + await bot.pin_chat_message( + chat_id=user_id, + message_id=sent.message_id, + disable_notification=True, + ) + _forward_pin_message_ids[user_id] = sent.message_id + logger.info("Pinned forward links message for user %d", user_id) + except Exception as e: + logger.warning( + "Failed to pin forward links message for user %d: %s", user_id, e + ) + + +async def _announce_forward_error(bot: Bot, error_text: str) -> None: + text = ( + "⚠ Failed to start dev port forwarding.\n\n" + f"{error_text}" + ) + for user_id in sorted(config.allowed_users): + await send_with_fallback(bot, user_id, text) + + +async def _run_port_forwarding(bot: Bot) -> None: + """Start forwarding in background and announce links/errors.""" + global _port_forward_manager + manager = PortForwardManager(config.forward_ports) + try: + tunnels = await manager.start() + _port_forward_manager = manager + await _announce_forward_links(bot, tunnels) + except asyncio.CancelledError: + await manager.stop() + raise + except Exception as e: + logger.error("Forwarding startup failed: %s", e) + await _announce_forward_error(bot, str(e)) + await manager.stop() # --- App lifecycle --- async def post_init(application: Application) -> None: - global session_monitor, _status_poll_task + global session_monitor, _status_poll_task, _port_forward_task await application.bot.delete_my_commands() @@ -1813,19 +2117,36 @@ async def post_init(application: Application) -> None: 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("esc", "Send Escape to interrupt active session"), 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(): + if config.supports_usage_command: + bot_commands.append(BotCommand("usage", "Show usage remaining")) + # Add provider slash commands + for cmd_name, desc in _provider_menu_commands().items(): bot_commands.append(BotCommand(cmd_name, desc)) await application.bot.set_my_commands(bot_commands) - # Re-resolve stale window IDs from persisted state against live tmux windows - await session_manager.resolve_stale_ids() + if config.forward_ports: + _port_forward_task = asyncio.create_task(_run_port_forwarding(application.bot)) + logger.info("Port forwarding task started for ports: %s", config.forward_ports) + + # Re-resolve stale window IDs from persisted state against live tmux windows. + # Guard with timeout so bot startup never hangs on slow tmux/state I/O. + try: + await asyncio.wait_for( + session_manager.resolve_stale_ids(), + timeout=_POST_INIT_RESOLVE_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.warning( + "Startup timeout: resolve_stale_ids exceeded %.1fs; continuing", + _POST_INIT_RESOLVE_TIMEOUT_SECONDS, + ) + except Exception as e: + logger.warning("Startup warning: resolve_stale_ids failed: %s", e) # Pre-fill global rate limiter bucket on restart. # AsyncLimiter starts at _level=0 (full burst capacity), but Telegram's @@ -1870,11 +2191,48 @@ async def post_shutdown(application: Application) -> None: await shutdown_workers() if session_monitor: - session_monitor.stop() + await session_monitor.stop() logger.info("Session monitor stopped") await close_transcribe_client() +async def post_stop(application: Application) -> None: + """Run before shutdown while Telegram HTTP client is still active.""" + global _port_forward_task, _port_forward_manager, _forward_pin_message_ids + + if _port_forward_task: + if not _port_forward_task.done(): + _port_forward_task.cancel() + try: + await _port_forward_task + except asyncio.CancelledError: + pass + _port_forward_task = None + + if _port_forward_manager: + await _port_forward_manager.stop() + _port_forward_manager = None + logger.info("Port forwarding stopped") + + for chat_id, message_id in list(_forward_pin_message_ids.items()): + try: + await application.bot.unpin_chat_message( + chat_id=chat_id, + message_id=message_id, + ) + logger.info( + "Unpinned forward links message for user %d (message_id=%d)", + chat_id, + message_id, + ) + except Exception as e: + logger.warning( + "Failed to unpin forward links message for user %d (message_id=%d): %s", + chat_id, + message_id, + e, + ) + _forward_pin_message_ids = {} def create_bot() -> Application: application = ( @@ -1882,6 +2240,7 @@ def create_bot() -> Application: .token(config.telegram_bot_token) .rate_limiter(AIORateLimiter(max_retries=5)) .post_init(post_init) + .post_stop(post_stop) .post_shutdown(post_shutdown) .build() ) @@ -1891,7 +2250,8 @@ def create_bot() -> Application: application.add_handler(CommandHandler("screenshot", screenshot_command)) application.add_handler(CommandHandler("esc", esc_command)) application.add_handler(CommandHandler("unbind", unbind_command)) - application.add_handler(CommandHandler("usage", usage_command)) + if config.supports_usage_command: + application.add_handler(CommandHandler("usage", usage_command)) application.add_handler(CallbackQueryHandler(callback_handler)) # Topic closed event — auto-kill associated window application.add_handler( @@ -1907,12 +2267,12 @@ def create_bot() -> Application: topic_edited_handler, ) ) - # Forward any other /command to Claude Code + # Forward any other /command to the active session application.add_handler(MessageHandler(filters.COMMAND, forward_command_handler)) application.add_handler( MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler) ) - # Photos: download and forward file path to Claude Code + # Photos: download and forward file path to the active session application.add_handler(MessageHandler(filters.PHOTO, photo_handler)) # Voice: transcribe via OpenAI and forward text to Claude Code application.add_handler(MessageHandler(filters.VOICE, voice_handler)) diff --git a/src/ccbot/codex_mapper.py b/src/ccbot/codex_mapper.py new file mode 100644 index 00000000..dce8047e --- /dev/null +++ b/src/ccbot/codex_mapper.py @@ -0,0 +1,294 @@ +"""Codex session mapper: tmux windows -> Codex rollout sessions. + +Codex does not expose Claude-style SessionStart hooks, so this module +discovers sessions from ~/.codex/sessions/**/rollout-*.jsonl and writes +window mappings into session_map.json in the existing ccbot format. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from .config import config +from .tmux_manager import tmux_manager +from .utils import atomic_write_json + +logger = logging.getLogger(__name__) + + +@dataclass +class CodexSessionMeta: + """Metadata extracted from a Codex rollout file.""" + + session_id: str + cwd: str + file_path: Path + started_ts: float + file_mtime: float + + +def _norm_path(path: str) -> str: + try: + return str(Path(path).resolve()) + except (OSError, ValueError): + return path + + +def _parse_iso_ts(ts: str) -> float: + if not ts: + return 0.0 + # Codex timestamps are ISO8601 with trailing Z. + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" + try: + return datetime.fromisoformat(ts).timestamp() + except ValueError: + return 0.0 + + +class CodexSessionMapper: + """Maps live tmux windows to Codex session IDs.""" + + def __init__( + self, + sessions_root: Path | None = None, + session_map_file: Path | None = None, + ) -> None: + self.sessions_root = ( + sessions_root if sessions_root is not None else config.codex_sessions_path + ) + self.session_map_file = ( + session_map_file + if session_map_file is not None + else config.session_map_file + ) + # file -> (mtime, size, parsed_meta_or_none) + self._meta_cache: dict[str, tuple[float, int, CodexSessionMeta | None]] = {} + + def _read_rollout_meta( + self, file_path: Path, file_mtime: float + ) -> CodexSessionMeta | None: + try: + with open(file_path, "r", encoding="utf-8") as f: + first = f.readline().strip() + except OSError: + return None + + if not first: + return None + try: + data = json.loads(first) + except json.JSONDecodeError: + return None + if data.get("type") != "session_meta": + return None + payload = data.get("payload", {}) + if not isinstance(payload, dict): + return None + + session_id = payload.get("id", "") + cwd = payload.get("cwd", "") + started_at = payload.get("timestamp", "") + if not session_id or not cwd: + return None + + return CodexSessionMeta( + session_id=session_id, + cwd=_norm_path(cwd), + file_path=file_path, + started_ts=_parse_iso_ts(started_at), + file_mtime=file_mtime, + ) + + def _scan_sessions(self) -> list[CodexSessionMeta]: + if not self.sessions_root.exists(): + return [] + + metas: list[CodexSessionMeta] = [] + seen_files: set[str] = set() + for file_path in self.sessions_root.rglob("rollout-*.jsonl"): + key = str(file_path) + seen_files.add(key) + try: + st = file_path.stat() + except OSError: + continue + cached = self._meta_cache.get(key) + if cached and cached[0] == st.st_mtime and cached[1] == st.st_size: + meta = cached[2] + else: + meta = self._read_rollout_meta(file_path, st.st_mtime) + self._meta_cache[key] = (st.st_mtime, st.st_size, meta) + if meta: + # Refresh mtime from current stat for cache-hit case + meta.file_mtime = st.st_mtime + metas.append(meta) + + # Drop deleted files from cache + deleted = [k for k in self._meta_cache if k not in seen_files] + for key in deleted: + del self._meta_cache[key] + + return metas + + async def sync_session_map(self) -> bool: + """Sync codex window->session mappings into session_map.json.""" + windows = await tmux_manager.list_windows() + metas = await asyncio.to_thread(self._scan_sessions) + + if self.session_map_file.exists(): + try: + session_map = json.loads(self.session_map_file.read_text()) + except (json.JSONDecodeError, OSError): + session_map = {} + else: + session_map = {} + + if not isinstance(session_map, dict): + session_map = {} + + prefix = f"{config.tmux_session_name}:" + by_cwd: dict[str, list[CodexSessionMeta]] = {} + for meta in metas: + by_cwd.setdefault(meta.cwd, []).append(meta) + for cwd in by_cwd: + by_cwd[cwd].sort( + key=lambda m: (m.file_mtime, m.started_ts), + reverse=True, + ) + all_metas = sorted( + metas, + key=lambda m: (m.file_mtime, m.started_ts), + reverse=True, + ) + preferred_sid = (config.codex_resume_session_id or "").strip() + preferred_meta: CodexSessionMeta | None = None + if preferred_sid: + for meta in all_metas: + if meta.session_id == preferred_sid: + preferred_meta = meta + break + + live_wids = {w.window_id for w in windows} + assigned_session_ids: set[str] = set() + next_entries: dict[str, dict[str, str]] = {} + + for w in windows: + key = f"{prefix}{w.window_id}" + existing = session_map.get(key, {}) + if not isinstance(existing, dict): + existing = {} + existing_provider = existing.get("provider", "claude") + + # Map windows that appear active, or were mapped as codex before. + # Codex often appears as "node" in tmux pane_current_command. + pane_cmd = (w.pane_current_command or "").lower() + if existing_provider != "codex" and pane_cmd in ( + "", + "bash", + "sh", + "zsh", + "fish", + ): + continue + + norm_cwd = _norm_path(w.cwd) + candidates = by_cwd.get(norm_cwd, []) + + chosen: CodexSessionMeta | None = None + existing_sid = existing.get("session_id", "") + has_existing_codex = existing_provider == "codex" and bool(existing_sid) + existing_meta: CodexSessionMeta | None = None + if existing_sid: + for meta in all_metas: + if meta.session_id == existing_sid: + existing_meta = meta + break + + if ( + preferred_meta is not None + and w.window_name == config.tmux_session_name + and preferred_meta.session_id not in assigned_session_ids + ): + # Explicit resume target should win over stale/existing mapping + # for the main ccbot window. + chosen = preferred_meta + + if chosen is None: + for meta in candidates: + if meta.session_id in assigned_session_ids: + continue + chosen = meta + break + + # If we couldn't resolve by cwd, keep previous mapping if still valid. + if ( + chosen is None + and existing_meta is not None + and existing_meta.session_id not in assigned_session_ids + ): + chosen = existing_meta + + # Fallback: choose the newest unassigned rollout across all projects. + # This handles "codex resume " where pane cwd can differ from session cwd. + if chosen is None: + for meta in all_metas: + if meta.session_id in assigned_session_ids: + continue + chosen = meta + break + + # Keep previous raw mapping only when we failed to parse its meta + # but file path still exists. + if chosen is None and has_existing_codex: + existing_fp = Path(existing.get("file_path", "")) + if existing_fp.exists(): + next_entries[key] = existing + assigned_session_ids.add(existing_sid) + continue + + if chosen is None: + continue + + assigned_session_ids.add(chosen.session_id) + next_entries[key] = { + "session_id": chosen.session_id, + "cwd": norm_cwd, + "window_name": w.window_name, + "provider": "codex", + "file_path": str(chosen.file_path), + } + + changed = False + # Remove stale codex entries for this tmux session. + stale_keys: list[str] = [] + for key, info in session_map.items(): + if not key.startswith(prefix): + continue + if key[len(prefix) :] not in live_wids: + stale_keys.append(key) + continue + if isinstance(info, dict) and info.get("provider") == "codex": + if key not in next_entries: + stale_keys.append(key) + for key in stale_keys: + del session_map[key] + changed = True + + for key, info in next_entries.items(): + if session_map.get(key) != info: + session_map[key] = info + changed = True + + if changed: + atomic_write_json(self.session_map_file, session_map) + logger.debug("Codex session_map updated (%d entries)", len(next_entries)) + return changed + + +codex_session_mapper = CodexSessionMapper() diff --git a/src/ccbot/config.py b/src/ccbot/config.py index ca3d6744..9e0550db 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -10,6 +10,7 @@ import logging import os +import re from pathlib import Path from dotenv import load_dotenv @@ -22,6 +23,17 @@ SENSITIVE_ENV_VARS = {"TELEGRAM_BOT_TOKEN", "ALLOWED_USERS", "OPENAI_API_KEY"} +def _extract_codex_resume_session_id(command: str) -> str: + """Extract session id from a command containing `resume `.""" + if not command: + return "" + + match = re.search(r"(?:^|\s)resume\s+([0-9a-fA-F-]{8,})\b", command) + if match: + return match.group(1) + return "" + + class Config: """Application configuration loaded from environment variables.""" @@ -61,8 +73,38 @@ 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.claude_command = os.getenv("CLAUDE_COMMAND", "claude") + provider = os.getenv("CCBOT_PROVIDER", "claude").strip().lower() + if provider not in ("claude", "codex"): + raise ValueError("CCBOT_PROVIDER must be one of: claude, codex") + self.provider = provider + + # Agent command to run in new windows. + # Backward compatible: + # - CLAUDE_COMMAND still works for provider=claude + # - CCBOT_AGENT_COMMAND overrides provider defaults + default_cmd = "codex" if self.provider == "codex" else "claude" + self.agent_command = os.getenv("CCBOT_AGENT_COMMAND") or os.getenv( + "CLAUDE_COMMAND", default_cmd + ) + self.codex_resume_session_id = ( + _extract_codex_resume_session_id(self.agent_command) + if self.provider == "codex" + else "" + ) + # Keep old attribute name for compatibility in the existing code path. + self.claude_command = self.agent_command + + # Provider capabilities and UI labels + self.agent_name = "Codex CLI" if self.provider == "codex" else "Claude Code" + self.supports_usage_command = self.provider == "claude" + # Interactive terminal prompts (approval/select) are used by both providers. + self.supports_claude_interactive_ui = self.provider in ("claude", "codex") + # Forward unknown slash commands for both providers by default: + # e.g. /status, /permissions, /clear, /compact. + slash_default = "true" + self.forward_slash_commands = ( + os.getenv("CCBOT_FORWARD_SLASH", slash_default).lower() == "true" + ) # All state files live under config_dir self.state_file = self.config_dir / "state.json" @@ -82,6 +124,19 @@ def __init__(self) -> None: else: self.claude_projects_path = Path.home() / ".claude" / "projects" + # Codex rollout logs root + self.codex_sessions_path = Path( + os.getenv( + "CCBOT_CODEX_SESSIONS_PATH", str(Path.home() / ".codex" / "sessions") + ) + ) + self.provider_data_root = ( + self.codex_sessions_path + if self.provider == "codex" + else self.claude_projects_path + ) + self.provider_supports_hook = self.provider == "claude" + self.monitor_poll_interval = float(os.getenv("MONITOR_POLL_INTERVAL", "2.0")) # Display user messages in history and real-time notifications @@ -99,6 +154,26 @@ def __init__(self) -> None: "OPENAI_BASE_URL", "https://api.openai.com/v1" ) + # Optional local port forwarding announcement on startup + # Format: "3000" or "3000,5173" + self.forward_ports: list[int] = [] + forward_ports_raw = os.getenv("CCBOT_FORWARD_PORTS", "").strip() + if forward_ports_raw: + for token in forward_ports_raw.split(","): + part = token.strip() + if not part: + continue + try: + port = int(part) + except ValueError as e: + raise ValueError( + f"CCBOT_FORWARD_PORTS contains non-numeric port: {part}" + ) from e + if port < 1 or port > 65535: + raise ValueError( + f"CCBOT_FORWARD_PORTS contains invalid port: {port}" + ) + self.forward_ports.append(port) # Scrub sensitive vars from os.environ so child processes never inherit them. # Values are already captured in Config attributes above. for var in SENSITIVE_ENV_VARS: @@ -106,12 +181,13 @@ def __init__(self) -> None: logger.debug( "Config initialized: dir=%s, token=%s..., allowed_users=%d, " - "tmux_session=%s, claude_projects_path=%s", + "provider=%s, tmux_session=%s, data_root=%s", self.config_dir, self.telegram_bot_token[:8], len(self.allowed_users), + self.provider, self.tmux_session_name, - self.claude_projects_path, + self.provider_data_root, ) 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..86d4429f 100644 --- a/src/ccbot/handlers/callback_data.py +++ b/src/ccbot/handlers/callback_data.py @@ -36,6 +36,7 @@ CB_ASK_DOWN = "aq:down:" # aq:down: CB_ASK_LEFT = "aq:left:" # aq:left: CB_ASK_RIGHT = "aq:right:" # aq:right: +CB_ASK_SELECT = "aq:num:" # aq:num:: CB_ASK_ESC = "aq:esc:" # aq:esc: CB_ASK_ENTER = "aq:enter:" # aq:enter: CB_ASK_SPACE = "aq:spc:" # aq:spc: diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index 174e3a9e..908ad550 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -8,6 +8,7 @@ Provides: - Keyboard navigation (up/down/left/right/enter/esc) + - Direct numeric selection for option lists (1..N) - Terminal capture and display - Interactive mode tracking per user and thread @@ -15,8 +16,11 @@ """ import logging +import re +import time from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.error import BadRequest from ..session import session_manager from ..terminal_parser import extract_interactive_content, is_interactive_ui @@ -28,6 +32,7 @@ CB_ASK_LEFT, CB_ASK_REFRESH, CB_ASK_RIGHT, + CB_ASK_SELECT, CB_ASK_SPACE, CB_ASK_TAB, CB_ASK_UP, @@ -45,6 +50,17 @@ # Track interactive mode: (user_id, thread_id_or_0) -> window_id _interactive_mode: dict[tuple[int, int], str] = {} +# Track parsed option state: (user_id, thread_id_or_0) -> (selected_index, total_options) +_interactive_choices: dict[tuple[int, int], tuple[int, int]] = {} +# Track latest rendered interactive payload to suppress duplicate re-sends. +# Value: (text, monotonic_timestamp) +_interactive_last_render: dict[tuple[int, int], tuple[str, float]] = {} + +_INTERACTIVE_RESEND_COOLDOWN_SECONDS = 30.0 + +_OPTION_NUMBER_RE = re.compile(r"^(?P\d{1,2})\.\s+(?P