From 857a0e6cfe2517550a0092fc2608b5aacc9e53b0 Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 3 Apr 2026 17:45:23 +0900 Subject: [PATCH 01/35] feat: add CCBOT_BATCH_WINDOW config option --- src/ccbot/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 22d1de76..1f3572ef 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -96,6 +96,10 @@ def __init__(self) -> None: os.getenv("CCBOT_SHOW_TOOL_CALLS", "true").lower() != "false" ) + # Batch tool_use/tool_result/thinking messages into one summary per N seconds + # Set to 0.0 to disable batching (sends each message individually) + self.batch_window = float(os.getenv("CCBOT_BATCH_WINDOW", "0.0")) + # Show hidden (dot) directories in directory browser self.show_hidden_dirs = ( os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true" From d401de1d5a3e399f142d56645aa60f7e52e99681 Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 3 Apr 2026 17:46:26 +0900 Subject: [PATCH 02/35] feat: add MessageBatcher for timed message grouping --- src/ccbot/message_batcher.py | 138 +++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/ccbot/message_batcher.py diff --git a/src/ccbot/message_batcher.py b/src/ccbot/message_batcher.py new file mode 100644 index 00000000..8cd80567 --- /dev/null +++ b/src/ccbot/message_batcher.py @@ -0,0 +1,138 @@ +"""Message batching — groups tool_use/tool_result/thinking into timed summaries. + +When CCBOT_BATCH_WINDOW > 0, tool calls and thinking messages are buffered +per (user_id, thread_id) and flushed as a single summary after N seconds, +or immediately before a final text response. + +Format: + ⚙️ 작업 중 (10초간 6건) + • Bash × 3 + • Thinking × 2 + • Task(frontend-developer: 컴포넌트 구현) × 1 +""" + +import asyncio +import json +import logging +import time +from collections import defaultdict +from dataclasses import dataclass + +from telegram import Bot + +from .handlers.message_sender import safe_send +from .session import session_manager + +logger = logging.getLogger(__name__) + + +@dataclass +class _Entry: + tool_name: str | None + content_type: str + text: str + + +class MessageBatcher: + """Buffers tool_use/tool_result/thinking messages and flushes as summaries.""" + + def __init__(self) -> None: + self._buffers: dict[tuple[int, int | None], list[_Entry]] = defaultdict(list) + self._start_times: dict[tuple[int, int | None], float] = {} + self._bot: Bot | None = None + self._window: float = 0.0 + self._task: asyncio.Task | None = None + + def start(self, bot: Bot, window: float) -> None: + """Start background flush timer.""" + self._bot = bot + self._window = window + self._task = asyncio.create_task(self._timer_loop()) + + def stop(self) -> None: + """Stop background flush timer.""" + if self._task: + self._task.cancel() + self._task = None + + def add( + self, + user_id: int, + thread_id: int | None, + tool_name: str | None, + content_type: str, + text: str, + ) -> None: + """Add a message to the buffer.""" + key = (user_id, thread_id) + if key not in self._start_times: + self._start_times[key] = time.monotonic() + self._buffers[key].append(_Entry(tool_name, content_type, text)) + + async def flush_and_send( + self, bot: Bot, user_id: int, thread_id: int | None + ) -> None: + """Flush buffer and send summary. Called before a final text response.""" + key = (user_id, thread_id) + entries = self._buffers.pop(key, []) + elapsed = time.monotonic() - self._start_times.pop(key, time.monotonic()) + if not entries: + return + text = _format_batch(entries, elapsed) + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send(bot, chat_id, text, message_thread_id=thread_id) + + async def _timer_loop(self) -> None: + """Periodically flush all non-empty buffers.""" + while True: + await asyncio.sleep(self._window) + if not self._bot: + continue + keys = list(self._buffers.keys()) + for key in keys: + entries = self._buffers.pop(key, []) + elapsed = time.monotonic() - self._start_times.pop(key, time.monotonic()) + if not entries: + continue + user_id, thread_id = key + text = _format_batch(entries, elapsed) + try: + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send( + self._bot, chat_id, text, message_thread_id=thread_id + ) + except Exception as e: + logger.error("Batcher flush error for key %s: %s", key, e) + + +def _extract_task_desc(text: str) -> str | None: + """Extract description from Task tool input JSON (first 50 chars).""" + try: + data = json.loads(text) + desc = data.get("description") or data.get("prompt", "") + return str(desc)[:50] if desc else None + except (json.JSONDecodeError, AttributeError, TypeError): + return None + + +def _format_batch(entries: list[_Entry], elapsed: float) -> str: + """Format buffered entries into a human-readable summary.""" + counts: dict[str, int] = {} + for e in entries: + if e.content_type == "thinking": + key = "Thinking" + elif e.tool_name in ("Task", "Agent"): + desc = _extract_task_desc(e.text) + key = f"Task({desc})" if desc else "Task" + else: + key = e.tool_name or e.content_type + counts[key] = counts.get(key, 0) + 1 + + lines = [f"⚙️ 작업 중 ({int(elapsed)}초간 {len(entries)}건)"] + for name, count in counts.items(): + lines.append(f"• {name} × {count}") + return "\n".join(lines) + + +# Module-level singleton — imported by bot.py +batcher = MessageBatcher() From 45154963d87274f2f4d13e29bf9c79dbc488d03b Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 3 Apr 2026 17:47:32 +0900 Subject: [PATCH 03/35] feat: route tool/thinking messages through MessageBatcher in handle_new_message --- src/ccbot/bot.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index f8270732..26321f91 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -130,6 +130,7 @@ from .handlers.response_builder import build_response_parts from .handlers.status_polling import status_poll_loop from .screenshot import text_to_image +from .message_batcher import batcher from .session import session_manager from .session_monitor import NewMessage, SessionMonitor from .terminal_parser import extract_bash_output, is_interactive_ui @@ -1771,6 +1772,14 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: if not config.show_tool_calls and msg.content_type in ("tool_use", "tool_result"): continue + # Batch tool_use/tool_result/thinking when CCBOT_BATCH_WINDOW > 0 + if config.batch_window > 0: + if msg.content_type in ("tool_use", "tool_result", "thinking"): + batcher.add(user_id, thread_id, msg.tool_name, msg.content_type, msg.text) + continue + if msg.content_type == "text" and msg.is_complete and msg.role == "assistant": + await batcher.flush_and_send(bot, user_id, thread_id) + parts = build_response_parts( msg.text, msg.is_complete, @@ -1852,6 +1861,9 @@ async def message_callback(msg: NewMessage) -> None: session_monitor = monitor logger.info("Session monitor started") + if config.batch_window > 0: + batcher.start(application.bot, config.batch_window) + # Start status polling task _status_poll_task = asyncio.create_task(status_poll_loop(application.bot)) logger.info("Status polling task started") @@ -1870,6 +1882,8 @@ async def post_shutdown(application: Application) -> None: _status_poll_task = None logger.info("Status polling stopped") + batcher.stop() + # Stop all queue workers await shutdown_workers() From ed93269c5d551b800a19c27c72043aee72895ecb Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 3 Apr 2026 17:58:12 +0900 Subject: [PATCH 04/35] fix: skip pre-final flush if elapsed < 5s to avoid spurious notifications --- src/ccbot/message_batcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ccbot/message_batcher.py b/src/ccbot/message_batcher.py index 8cd80567..ad178b1d 100644 --- a/src/ccbot/message_batcher.py +++ b/src/ccbot/message_batcher.py @@ -76,7 +76,7 @@ async def flush_and_send( key = (user_id, thread_id) entries = self._buffers.pop(key, []) elapsed = time.monotonic() - self._start_times.pop(key, time.monotonic()) - if not entries: + if not entries or elapsed < 5.0: return text = _format_batch(entries, elapsed) chat_id = session_manager.resolve_chat_id(user_id, thread_id) From c24701b3e7fcde5be0a83f390f6751290050ab6f Mon Sep 17 00:00:00 2001 From: TejNote Date: Tue, 7 Apr 2026 10:37:57 +0900 Subject: [PATCH 05/35] fix: hook - normalize tmux session name using TMUX_SESSION_NAME env Group session copies (ccbot-15, ccbot-12) were recording session_map keys with their own names (ccbot-15:@4) instead of the canonical name (ccbot:@4). Bot only processes ccbot: prefix, so these sessions were invisible. Fix: read TMUX_SESSION_NAME from .ccbot/.env and use as canonical prefix. --- src/ccbot/hook.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index eaf9f411..bed7738f 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -216,8 +216,25 @@ def hook_main() -> None: ) return tmux_session_name, window_id, window_name = parts + + # Use canonical session name from .ccbot/.env (TMUX_SESSION_NAME) if set. + # This handles tmux group session copies (ccbot-15, ccbot-12, etc.) which + # would otherwise record keys like "ccbot-15:@4" that the bot ignores. + from .utils import ccbot_dir as _ccbot_dir + + _env_file = _ccbot_dir() / ".env" + canonical_session = tmux_session_name # fallback: current tmux session name + if _env_file.exists(): + for _line in _env_file.read_text().splitlines(): + _line = _line.strip() + if _line.startswith("TMUX_SESSION_NAME="): + _val = _line.split("=", 1)[1].strip() + if _val: + canonical_session = _val + break + # Key uses window_id for uniqueness - session_window_key = f"{tmux_session_name}:{window_id}" + session_window_key = f"{canonical_session}:{window_id}" logger.debug( "tmux key=%s, window_name=%s, session_id=%s, cwd=%s", From cac3c1e03b7a90632ffdc62a5ab1e3b358b0ed4f Mon Sep 17 00:00:00 2001 From: TejNote Date: Tue, 7 Apr 2026 10:39:59 +0900 Subject: [PATCH 06/35] refactor: hook - remove unnecessary import alias for ccbot_dir Co-Authored-By: Claude Sonnet 4.6 --- src/ccbot/hook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index bed7738f..e5540cad 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -220,9 +220,9 @@ def hook_main() -> None: # Use canonical session name from .ccbot/.env (TMUX_SESSION_NAME) if set. # This handles tmux group session copies (ccbot-15, ccbot-12, etc.) which # would otherwise record keys like "ccbot-15:@4" that the bot ignores. - from .utils import ccbot_dir as _ccbot_dir + from .utils import ccbot_dir - _env_file = _ccbot_dir() / ".env" + _env_file = ccbot_dir() / ".env" canonical_session = tmux_session_name # fallback: current tmux session name if _env_file.exists(): for _line in _env_file.read_text().splitlines(): From 3f6007cf5b25b8525fc478495f048de3aa705495 Mon Sep 17 00:00:00 2001 From: TejNote Date: Tue, 7 Apr 2026 10:40:56 +0900 Subject: [PATCH 07/35] fix: add 150ms delay before status check to prevent stale status reappearance Claude TUI does not update its status line immediately after completing a response. Reading the terminal right after sending the response captured the previous 'thinking...' status and re-sent it as a new status message. Fix: sleep 150ms before _check_and_send_status at all 3 call sites. --- src/ccbot/handlers/message_queue.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index bdd28038..e83a0612 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -321,6 +321,7 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No link_preview_options=NO_LINK_PREVIEW, ) await _send_task_images(bot, chat_id, task) + await asyncio.sleep(0.15) # Wait for Claude TUI to update status await _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: @@ -336,6 +337,7 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No link_preview_options=NO_LINK_PREVIEW, ) await _send_task_images(bot, chat_id, task) + await asyncio.sleep(0.15) # Wait for Claude TUI to update status await _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: @@ -382,6 +384,7 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No await _send_task_images(bot, chat_id, task) # 5. After content, check and send status + await asyncio.sleep(0.15) # Wait for Claude TUI to update status after response await _check_and_send_status(bot, user_id, wid, task.thread_id) From 85667824f9367a5e39c424b60b25617021686038 Mon Sep 17 00:00:00 2001 From: TejNote Date: Tue, 7 Apr 2026 10:43:14 +0900 Subject: [PATCH 08/35] fix: check Claude busy state before send_keys to prevent silent command drops Claude TUI does not process key input during response generation. Commands sent while Claude is working were silently dropped with no feedback to user. Fix: capture pane and parse status line in send_to_window. If status contains 'esc to interrupt', return an error message instead of sending keys. --- src/ccbot/session.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 173293b1..d4c182f0 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -33,6 +33,7 @@ import aiofiles from .config import config +from .terminal_parser import parse_status_line from .tmux_manager import tmux_manager from .transcript_parser import TranscriptParser from .utils import atomic_write_json @@ -823,6 +824,15 @@ async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: window = await tmux_manager.find_window_by_id(window_id) if not window: return False, "Window not found (may have been closed)" + + # Check if Claude is currently generating a response. + # Claude TUI ignores key input while working, causing commands to be silently dropped. + pane_text = await tmux_manager.capture_pane(window.window_id) + if pane_text: + status = parse_status_line(pane_text) + if status and "esc to interrupt" in status.lower(): + return False, "Claude가 응답 생성 중입니다. 완료 후 다시 시도해주세요." + success = await tmux_manager.send_keys(window.window_id, text) if success: return True, f"Sent to {display}" From 1d9bc06d0a392972ba99e2e40bc5e03fca2ab48e Mon Sep 17 00:00:00 2001 From: TejNote Date: Tue, 7 Apr 2026 10:46:57 +0900 Subject: [PATCH 09/35] fix: hook - strip quotes from .env values, remove duplicate import --- src/ccbot/hook.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index e5540cad..23a6c58a 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -228,7 +228,7 @@ def hook_main() -> None: for _line in _env_file.read_text().splitlines(): _line = _line.strip() if _line.startswith("TMUX_SESSION_NAME="): - _val = _line.split("=", 1)[1].strip() + _val = _line.split("=", 1)[1].strip().strip("\"'") if _val: canonical_session = _val break @@ -245,8 +245,6 @@ def hook_main() -> None: ) # 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) From 3836a2a6c4165bdca257a2528a28ec201de84af0 Mon Sep 17 00:00:00 2001 From: TejNote Date: Wed, 8 Apr 2026 17:09:07 +0900 Subject: [PATCH 10/35] feat: add SkillRegistry for plugin skill scanning and management Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/skill_registry.py | 201 ++++++++++++++++++++++ tests/ccbot/test_skill_registry.py | 262 +++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 src/ccbot/skill_registry.py create mode 100644 tests/ccbot/test_skill_registry.py diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py new file mode 100644 index 00000000..ce8d9217 --- /dev/null +++ b/src/ccbot/skill_registry.py @@ -0,0 +1,201 @@ +"""Skill registry for Claude Code plugin skills. + +Scans ~/.claude/plugins/cache/ for installed plugin skills by parsing +SKILL.md frontmatter, and provides sorted command lists for Telegram +bot menu registration. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ccbot.utils import atomic_write_json + + +@dataclass +class SkillInfo: + """Metadata for a single Claude Code plugin skill.""" + + name: str # Original skill name (e.g. "systematic-debugging") + command: str # Telegram command (e.g. "systematic_debugging") + description: str # Short description for command menu (max 256 chars) + plugin: str # Parent plugin name (e.g. "superpowers") + slash_command: str # Command to send to Claude (e.g. "/systematic-debugging") + + +class SkillRegistry: + """Scan and manage Claude Code plugin skills for Telegram bot integration.""" + + def __init__(self, plugins_dir: Path, state_path: Path) -> None: + self._plugins_dir = plugins_dir + self._state_path = state_path + self._skills: dict[str, SkillInfo] = {} + self._state: dict[str, Any] = self._load_state() + + def _load_state(self) -> dict[str, Any]: + """Load persisted state from disk.""" + if self._state_path.exists(): + try: + with open(self._state_path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {"favorites": [], "usage": {}} + + def _save_state(self) -> None: + """Persist state to disk atomically.""" + atomic_write_json(self._state_path, self._state) + + def scan(self) -> list[SkillInfo]: + """Scan plugins cache directory and return discovered skills.""" + if not self._plugins_dir.is_dir(): + return [] + + # Collect raw skill entries: (plugin_name, skill_name, description) + raw: list[tuple[str, str, str]] = [] + for marketplace_dir in self._plugins_dir.iterdir(): + if not marketplace_dir.is_dir(): + continue + for plugin_dir in marketplace_dir.iterdir(): + if not plugin_dir.is_dir(): + continue + plugin_name = plugin_dir.name + # Find the version directory (take the first one) + for version_dir in plugin_dir.iterdir(): + if not version_dir.is_dir(): + continue + skills_dir = version_dir / "skills" + if not skills_dir.is_dir(): + continue + for skill_dir in skills_dir.iterdir(): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + continue + name, description = self._parse_skill_md(skill_md) + if name: + raw.append((plugin_name, name, description)) + + # Convert to commands and detect collisions + command_map: dict[str, list[tuple[str, str, str]]] = {} + for plugin_name, skill_name, description in raw: + cmd = self._to_command(skill_name) + command_map.setdefault(cmd, []).append( + (plugin_name, skill_name, description) + ) + + skills: dict[str, SkillInfo] = {} + for cmd, entries in command_map.items(): + if len(entries) == 1: + plugin_name, skill_name, description = entries[0] + info = SkillInfo( + name=skill_name, + command=cmd, + description=description[:256], + plugin=plugin_name, + slash_command=f"/{skill_name}", + ) + skills[cmd] = info + else: + # Name collision — prefix with shortened plugin name + for plugin_name, skill_name, description in entries: + prefix = plugin_name[:10].lower().replace("-", "_") + prefixed_cmd = self._to_command(f"{prefix}_{skill_name}") + info = SkillInfo( + name=skill_name, + command=prefixed_cmd, + description=description[:256], + plugin=plugin_name, + slash_command=f"/{skill_name}", + ) + skills[prefixed_cmd] = info + + self._skills = skills + return list(skills.values()) + + @staticmethod + def _parse_skill_md(path: Path) -> tuple[str, str]: + """Parse YAML frontmatter from SKILL.md for name and description.""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return ("", "") + + # Match YAML frontmatter between --- delimiters + match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL) + if not match: + return ("", "") + + frontmatter = match.group(1) + name = "" + description = "" + for line in frontmatter.splitlines(): + line = line.strip() + if line.startswith("name:"): + name = line[5:].strip().strip("\"'") + elif line.startswith("description:"): + description = line[12:].strip().strip("\"'") + + return (name, description) + + @staticmethod + def _to_command(name: str) -> str: + """Convert skill name to Telegram command. + + Hyphens become underscores, lowercase, max 32 chars. + """ + cmd = name.lower().replace("-", "_") + return cmd[:32] + + def is_skill(self, command: str) -> bool: + """Check if a command maps to a registered skill.""" + return command in self._skills + + def get_slash_command(self, command: str) -> str: + """Get original slash command for a Telegram command.""" + info = self._skills.get(command) + return info.slash_command if info else "" + + def record_usage(self, command: str, project_dir: str | None) -> None: + """Record skill usage for a project directory.""" + if project_dir is None: + return + usage: dict[str, dict[str, int]] = self._state.setdefault("usage", {}) + project_usage = usage.setdefault(project_dir, {}) + project_usage[command] = project_usage.get(command, 0) + 1 + self._save_state() + + def toggle_favorite(self, command: str) -> bool: + """Toggle favorite status for a command. Returns new state.""" + favorites: list[str] = self._state.setdefault("favorites", []) + if command in favorites: + favorites.remove(command) + self._save_state() + return False + favorites.append(command) + self._save_state() + return True + + def is_favorite(self, command: str) -> bool: + """Check if a command is favorited.""" + return command in self._state.get("favorites", []) + + def get_sorted_skills(self, project_dir: str | None = None) -> list[SkillInfo]: + """Get skills sorted by: favorites first, then usage count, then alpha.""" + skills = list(self._skills.values()) + favorites: list[str] = self._state.get("favorites", []) + usage: dict[str, int] = {} + if project_dir: + usage = self._state.get("usage", {}).get(project_dir, {}) + + def sort_key(s: SkillInfo) -> tuple[int, int, str]: + is_fav = 0 if s.command in favorites else 1 + use_count = -(usage.get(s.command, 0)) + return (is_fav, use_count, s.command) + + return sorted(skills, key=sort_key) diff --git a/tests/ccbot/test_skill_registry.py b/tests/ccbot/test_skill_registry.py new file mode 100644 index 00000000..358cbfbb --- /dev/null +++ b/tests/ccbot/test_skill_registry.py @@ -0,0 +1,262 @@ +"""Tests for SkillRegistry — plugin skill scanning and management.""" + +from pathlib import Path + +from ccbot.skill_registry import SkillRegistry + + +def _make_skill_md( + base: Path, + marketplace: str, + plugin: str, + version: str, + skill_name: str, + description: str, +) -> Path: + """Create a fake SKILL.md in the expected directory structure.""" + skill_dir = base / marketplace / plugin / version / "skills" / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + skill_md = skill_dir / "SKILL.md" + skill_md.write_text( + f'---\nname: {skill_name}\ndescription: "{description}"\n---\n\nBody text here.\n', + encoding="utf-8", + ) + return skill_md + + +class TestScan: + def test_scan_finds_all_skills(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "brainstorming", + "Brainstorm ideas", + ) + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "systematic-debugging", + "Debug systematically", + ) + _make_skill_md( + plugins_dir, + "official", + "pr-review-toolkit", + "1.0.0", + "code-reviewer", + "Review code", + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + skills = reg.scan() + + assert len(skills) == 3 + names = {s.name for s in skills} + assert names == {"brainstorming", "systematic-debugging", "code-reviewer"} + + def test_scan_skips_non_skill_dirs(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + # Create a commands/ directory (should be ignored) + commands_dir = ( + plugins_dir + / "official" + / "superpowers" + / "5.0.7" + / "commands" + / "some-command" + ) + commands_dir.mkdir(parents=True) + (commands_dir / "SKILL.md").write_text( + '---\nname: some-command\ndescription: "Should be ignored"\n---\n' + ) + + # Create a valid skill + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "brainstorming", + "Brainstorm ideas", + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + skills = reg.scan() + + assert len(skills) == 1 + assert skills[0].name == "brainstorming" + + def test_scan_handles_missing_dir(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "nonexistent" + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + skills = reg.scan() + + assert skills == [] + + +class TestCommandConversion: + def test_command_name_converts_hyphens(self) -> None: + assert ( + SkillRegistry._to_command("systematic-debugging") == "systematic_debugging" + ) + + def test_slash_command_preserves_original(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "systematic-debugging", + "Debug", + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + reg.scan() + + assert reg.get_slash_command("systematic_debugging") == "/systematic-debugging" + + +class TestNameCollision: + def test_name_collision_adds_prefix(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, "official", "plugin-a", "1.0.0", "review", "Review A" + ) + _make_skill_md( + plugins_dir, "official", "plugin-b", "1.0.0", "review", "Review B" + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + skills = reg.scan() + + assert len(skills) == 2 + commands = {s.command for s in skills} + assert "plugin_a_review" in commands + assert "plugin_b_review" in commands + + +class TestFavorites: + def test_toggle_favorite(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "brainstorming", + "Brainstorm", + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + reg.scan() + + # Toggle on + result = reg.toggle_favorite("brainstorming") + assert result is True + assert reg.is_favorite("brainstorming") is True + + # Toggle off + result = reg.toggle_favorite("brainstorming") + assert result is False + assert reg.is_favorite("brainstorming") is False + + def test_favorite_persists_to_disk(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + state_path = tmp_path / "state.json" + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "brainstorming", + "Brainstorm", + ) + + reg1 = SkillRegistry(plugins_dir, state_path) + reg1.scan() + reg1.toggle_favorite("brainstorming") + + # New instance should load persisted favorites + reg2 = SkillRegistry(plugins_dir, state_path) + assert reg2.is_favorite("brainstorming") is True + + +class TestUsage: + def test_record_usage(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + state_path = tmp_path / "state.json" + _make_skill_md( + plugins_dir, + "official", + "superpowers", + "5.0.7", + "brainstorming", + "Brainstorm", + ) + + reg = SkillRegistry(plugins_dir, state_path) + reg.scan() + reg.record_usage("brainstorming", "/path/to/project") + reg.record_usage("brainstorming", "/path/to/project") + + # Verify state persisted + import json + + state = json.loads(state_path.read_text()) + assert state["usage"]["/path/to/project"]["brainstorming"] == 2 + + def test_record_usage_none_project_is_noop(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + state_path = tmp_path / "state.json" + + reg = SkillRegistry(plugins_dir, state_path) + reg.record_usage("brainstorming", None) + + # State file should not exist (no save happened) + assert not state_path.exists() + + +class TestSorting: + def test_sorted_skills_favorites_first(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, "official", "superpowers", "5.0.7", "aaa-skill", "First alpha" + ) + _make_skill_md( + plugins_dir, "official", "superpowers", "5.0.7", "zzz-skill", "Last alpha" + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + reg.scan() + reg.toggle_favorite("zzz_skill") + + sorted_skills = reg.get_sorted_skills() + assert sorted_skills[0].command == "zzz_skill" + assert sorted_skills[1].command == "aaa_skill" + + def test_sorted_skills_usage_order(self, tmp_path: Path) -> None: + plugins_dir = tmp_path / "cache" + _make_skill_md( + plugins_dir, "official", "superpowers", "5.0.7", "aaa-skill", "First alpha" + ) + _make_skill_md( + plugins_dir, "official", "superpowers", "5.0.7", "zzz-skill", "Last alpha" + ) + + reg = SkillRegistry(plugins_dir, tmp_path / "state.json") + reg.scan() + + project = "/my/project" + reg.record_usage("zzz_skill", project) + reg.record_usage("zzz_skill", project) + reg.record_usage("aaa_skill", project) + + sorted_skills = reg.get_sorted_skills(project_dir=project) + assert sorted_skills[0].command == "zzz_skill" + assert sorted_skills[1].command == "aaa_skill" From 57c97faf3b39bc49353088d0939e3bfe19aae37e Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 09:42:13 +0900 Subject: [PATCH 11/35] fix: sanitize command names, improve fallback and add logging to SkillRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/skill_registry.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index ce8d9217..e9046825 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import logging import re from dataclasses import dataclass from pathlib import Path @@ -15,6 +16,8 @@ from ccbot.utils import atomic_write_json +logger = logging.getLogger(__name__) + @dataclass class SkillInfo: @@ -53,6 +56,7 @@ def _save_state(self) -> None: def scan(self) -> list[SkillInfo]: """Scan plugins cache directory and return discovered skills.""" if not self._plugins_dir.is_dir(): + logger.warning("Plugins directory not found: %s", self._plugins_dir) return [] # Collect raw skill entries: (plugin_name, skill_name, description) @@ -116,6 +120,7 @@ def scan(self) -> list[SkillInfo]: skills[prefixed_cmd] = info self._skills = skills + logger.info("Scanned %d skills from %s", len(skills), self._plugins_dir) return list(skills.values()) @staticmethod @@ -150,6 +155,7 @@ def _to_command(name: str) -> str: Hyphens become underscores, lowercase, max 32 chars. """ cmd = name.lower().replace("-", "_") + cmd = re.sub(r"[^a-z0-9_]", "", cmd) return cmd[:32] def is_skill(self, command: str) -> bool: @@ -159,7 +165,7 @@ def is_skill(self, command: str) -> bool: def get_slash_command(self, command: str) -> str: """Get original slash command for a Telegram command.""" info = self._skills.get(command) - return info.slash_command if info else "" + return info.slash_command if info else f"/{command}" def record_usage(self, command: str, project_dir: str | None) -> None: """Record skill usage for a project directory.""" From d9e6fe2118b218cdbe811661f292ac4cf30ed211 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 09:45:29 +0900 Subject: [PATCH 12/35] =?UTF-8?q?feat:=20integrate=20SkillRegistry=20into?= =?UTF-8?q?=20bot=20=E2=80=94=20command=20menu,=20/favorite,=20usage=20tra?= =?UTF-8?q?cking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/bot.py | 136 ++++++++++++++++++++++++---- src/ccbot/handlers/callback_data.py | 3 + 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 26321f91..f1069d57 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -58,6 +58,7 @@ ) from .config import config +from .skill_registry import SkillRegistry from .handlers.callback_data import ( CB_ASK_DOWN, CB_ASK_ENTER, @@ -73,6 +74,7 @@ CB_DIR_PAGE, CB_DIR_SELECT, CB_DIR_UP, + CB_FAV_TOGGLE, CB_HISTORY_NEXT, CB_HISTORY_PREV, CB_SESSION_CANCEL, @@ -157,6 +159,28 @@ "model": "↗ Switch AI model", } +_skill_registry: SkillRegistry | None = None + + +def _build_bot_commands() -> list[BotCommand]: + """Build the full list of bot commands: built-in + CC + skills.""" + 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"), + BotCommand("favorite", "Toggle skill favorites"), + ] + for cmd_name, desc in CC_COMMANDS.items(): + commands.append(BotCommand(cmd_name, desc)) + if _skill_registry: + for skill in _skill_registry.get_sorted_skills(): + commands.append(BotCommand(skill.command, f"↗ {skill.description}")) + return commands + def is_user_allowed(user_id: int | None) -> bool: return user_id is not None and config.is_user_allowed(user_id) @@ -350,6 +374,49 @@ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_reply(update.message, f"```\n{trimmed}\n```") +async def favorite_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show skill list with favorite toggles.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return + if not update.message: + return + if not _skill_registry: + await safe_reply(update.message, "❌ No skills registered.") + return + + skills = _skill_registry.get_sorted_skills() + if not skills: + await safe_reply(update.message, "❌ No skills found.") + return + + keyboard = _build_favorite_keyboard() + await safe_reply( + update.message, + "⭐ Toggle skill favorites:", + reply_markup=keyboard, + ) + + +def _build_favorite_keyboard() -> InlineKeyboardMarkup: + """Build inline keyboard for favorite toggles.""" + assert _skill_registry is not None + skills = _skill_registry.get_sorted_skills() + buttons: list[list[InlineKeyboardButton]] = [] + for skill in skills: + prefix = "⭐ " if _skill_registry.is_favorite(skill.command) else "" + label = f"{prefix}{skill.name}" + buttons.append( + [ + InlineKeyboardButton( + label, + callback_data=f"{CB_FAV_TOGGLE}{skill.command}"[:64], + ) + ] + ) + return InlineKeyboardMarkup(buttons) + + # --- Screenshot keyboard with quick control keys --- # key_id → (tmux_key, enter, literal) @@ -517,6 +584,18 @@ async def forward_command_handler( await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") return + # Convert skill commands to original slash commands + if _skill_registry: + cmd_name = cc_slash.lstrip("/").split()[0] + if _skill_registry.is_skill(cmd_name): + original_slash = _skill_registry.get_slash_command(cmd_name) + # Preserve any arguments after the command + args = cc_slash.split(None, 1)[1] if " " in cc_slash else "" + cc_slash = f"{original_slash} {args}".rstrip() + # Record usage + ws = session_manager.get_window_state(wid) + _skill_registry.record_usage(cmd_name, ws.cwd or None) + display = session_manager.get_display_name(wid) logger.info( "Forwarding command %s to window %s (user=%d)", cc_slash, display, user.id @@ -1561,6 +1640,26 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - logger.error(f"Failed to refresh screenshot: {e}") await query.answer("Failed to refresh", show_alert=True) + # Favorite toggle + elif data.startswith(CB_FAV_TOGGLE): + cmd = data[len(CB_FAV_TOGGLE) :] + if _skill_registry and _skill_registry.is_skill(cmd): + is_fav = _skill_registry.toggle_favorite(cmd) + label = "⭐ Added to favorites" if is_fav else "Removed from favorites" + await query.answer(label) + # Rebuild keyboard with updated stars + keyboard = _build_favorite_keyboard() + await safe_edit( + query, + "⭐ Toggle skill favorites:", + reply_markup=keyboard, + ) + # Re-register commands with new order + new_commands = _build_bot_commands() + await context.bot.set_my_commands(new_commands) + else: + await query.answer("Unknown skill") + elif data == "noop": await query.answer() @@ -1769,15 +1868,24 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: await clear_interactive_msg(user_id, bot, thread_id) # Skip tool call notifications when CCBOT_SHOW_TOOL_CALLS=false - if not config.show_tool_calls and msg.content_type in ("tool_use", "tool_result"): + if not config.show_tool_calls and msg.content_type in ( + "tool_use", + "tool_result", + ): continue # Batch tool_use/tool_result/thinking when CCBOT_BATCH_WINDOW > 0 if config.batch_window > 0: if msg.content_type in ("tool_use", "tool_result", "thinking"): - batcher.add(user_id, thread_id, msg.tool_name, msg.content_type, msg.text) + batcher.add( + user_id, thread_id, msg.tool_name, msg.content_type, msg.text + ) continue - if msg.content_type == "text" and msg.is_complete and msg.role == "assistant": + if ( + msg.content_type == "text" + and msg.is_complete + and msg.role == "assistant" + ): await batcher.flush_and_send(bot, user_id, thread_id) parts = build_response_parts( @@ -1818,23 +1926,18 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: async def post_init(application: Application) -> None: - global session_monitor, _status_poll_task + global session_monitor, _status_poll_task, _skill_registry 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)) + # Initialize skill registry and scan plugins + _skill_registry = SkillRegistry( + plugins_dir=Path.home() / ".claude" / "plugins" / "cache", + state_path=config.config_dir / "skill_state.json", + ) + _skill_registry.scan() + bot_commands = _build_bot_commands() await application.bot.set_my_commands(bot_commands) # Re-resolve stale window IDs from persisted state against live tmux windows @@ -1910,6 +2013,7 @@ def create_bot() -> Application: application.add_handler(CommandHandler("esc", esc_command)) application.add_handler(CommandHandler("unbind", unbind_command)) application.add_handler(CommandHandler("usage", usage_command)) + application.add_handler(CommandHandler("favorite", favorite_command)) application.add_handler(CallbackQueryHandler(callback_handler)) # Topic closed event — auto-kill associated window application.add_handler( diff --git a/src/ccbot/handlers/callback_data.py b/src/ccbot/handlers/callback_data.py index e4846aff..ef5ad247 100644 --- a/src/ccbot/handlers/callback_data.py +++ b/src/ccbot/handlers/callback_data.py @@ -49,3 +49,6 @@ # Screenshot control keys CB_KEYS_PREFIX = "kb:" # kb:: + +# Favorite toggle +CB_FAV_TOGGLE = "fav:" # fav: From 5265b8204f5e713cdb19aeb238ef6d09e52cce86 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 09:48:10 +0900 Subject: [PATCH 13/35] docs: update README with plugin skill menu feature --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index e7ee01dc..fb76bf89 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ In fact, CCBot itself was built this way — iterating on itself through Claude - **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`) +- **Plugin skill menu** — Installed Claude Code plugin skills (superpowers, pr-review-toolkit, etc.) are auto-discovered and registered in the Telegram `/` command menu +- **Skill favorites** — Toggle favorites via `/favorite` to pin frequently-used skills to the top of the menu +- **Usage-based sorting** — Skills are sorted by per-project usage frequency, so your most-used skills surface first - **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 @@ -151,6 +154,7 @@ uv run ccbot | `/history` | Message history for this topic | | `/screenshot` | Capture terminal screenshot | | `/esc` | Send Escape to interrupt Claude | +| `/favorite` | Toggle skill favorites | **Claude Code commands (forwarded via tmux):** @@ -164,6 +168,20 @@ uv run ccbot Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/review`, `/doctor`, `/init`). +**Plugin skills (auto-discovered):** + +Installed Claude Code plugins are automatically scanned at startup. Their skills appear in the Telegram `/` command menu alongside built-in commands. For example, if you have `superpowers` installed: + +| Command | Description | +| -------------------- | ----------------------------------------- | +| `/brainstorming` | ↗ Design features through collaborative dialogue | +| `/systematic_debugging` | ↗ Debug issues systematically | +| `/writing_plans` | ↗ Write implementation plans | +| `/test_driven_development` | ↗ TDD workflow | +| ... | (all installed plugin skills) | + +Use `/favorite` to pin your most-used skills to the top of the menu. + ### Topic Workflow **1 Topic = 1 Window = 1 Session.** The bot runs in Telegram Forum (topics) mode. @@ -243,6 +261,7 @@ The window must be in the `ccbot` tmux session (configurable via `TMUX_SESSION_N | `$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) | +| `$CCBOT_DIR/skill_state.json` | Skill favorites and per-project usage counts | | `~/.claude/projects/` | Claude Code session data (read-only) | ## File Structure @@ -262,6 +281,7 @@ src/ccbot/ ├── 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 +├── skill_registry.py # Plugin skill discovery and Telegram command registration ├── 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 From ad707d29f62a96e7e41d321cee439bf98a2b460b Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 09:55:00 +0900 Subject: [PATCH 14/35] fix: truncate skill description with prefix to 256 chars, skip skills without description --- src/ccbot/bot.py | 3 ++- src/ccbot/skill_registry.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index f1069d57..65630338 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -178,7 +178,8 @@ def _build_bot_commands() -> list[BotCommand]: commands.append(BotCommand(cmd_name, desc)) if _skill_registry: for skill in _skill_registry.get_sorted_skills(): - commands.append(BotCommand(skill.command, f"↗ {skill.description}")) + desc = f"↗ {skill.description}"[:256] + commands.append(BotCommand(skill.command, desc)) return commands diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index e9046825..a2757529 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -82,7 +82,7 @@ def scan(self) -> list[SkillInfo]: if not skill_md.is_file(): continue name, description = self._parse_skill_md(skill_md) - if name: + if name and description: raw.append((plugin_name, name, description)) # Convert to commands and detect collisions From 0b1927dbf38b6c041d73b5ed1c1fe1f026088580 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 10:08:58 +0900 Subject: [PATCH 15/35] fix: cap total bot commands at 100 (Telegram API limit) --- src/ccbot/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 65630338..9b4d3f0a 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -177,7 +177,9 @@ def _build_bot_commands() -> list[BotCommand]: for cmd_name, desc in CC_COMMANDS.items(): commands.append(BotCommand(cmd_name, desc)) if _skill_registry: - for skill in _skill_registry.get_sorted_skills(): + # Telegram Bot API allows max 100 commands total + remaining = 100 - len(commands) + for skill in _skill_registry.get_sorted_skills()[:remaining]: desc = f"↗ {skill.description}"[:256] commands.append(BotCommand(skill.command, desc)) return commands From fa98f822b6c21e3553493efc2a518fe29f269a31 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 10:15:13 +0900 Subject: [PATCH 16/35] fix: budget skill descriptions to stay within Telegram's ~5000 char total limit --- src/ccbot/bot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 9b4d3f0a..7f9f6c62 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -177,10 +177,14 @@ def _build_bot_commands() -> list[BotCommand]: for cmd_name, desc in CC_COMMANDS.items(): commands.append(BotCommand(cmd_name, desc)) if _skill_registry: - # Telegram Bot API allows max 100 commands total + # Telegram Bot API has undocumented ~5000 char total description limit. + # Cap per-skill description to fit more skills within the budget. remaining = 100 - len(commands) - for skill in _skill_registry.get_sorted_skills()[:remaining]: - desc = f"↗ {skill.description}"[:256] + desc_budget = 5000 - sum(len(c.description) for c in commands) + skills = _skill_registry.get_sorted_skills()[:remaining] + max_desc = max(10, desc_budget // len(skills)) if skills else 0 + for skill in skills: + desc = f"↗ {skill.description}"[:max_desc] commands.append(BotCommand(skill.command, desc)) return commands From 3a0e0557615bfa46de101a8b709e98a70b4c687c Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 10:34:05 +0900 Subject: [PATCH 17/35] fix: scan latest version only, add Korean descriptions, remove stale plugins --- src/ccbot/bot.py | 96 +++++++++++++++++++++++++++++++------ src/ccbot/skill_registry.py | 53 +++++++++++++------- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 7f9f6c62..5a62d0a0 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -151,28 +151,90 @@ # 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", + "clear": "↗ 대화 기록 초기화", + "compact": "↗ 컨텍스트 압축", + "cost": "↗ 토큰/비용 확인", + "help": "↗ Claude Code 도움말", + "memory": "↗ CLAUDE.md 편집", + "model": "↗ AI 모델 전환", } _skill_registry: SkillRegistry | None = None +# Korean descriptions for known skills (command_name → description) +_SKILL_DESC_KO: dict[str, str] = { + # superpowers + "brainstorming": "브레인스토밍 — 기능 설계 전 아이디어 구체화", + "writing_plans": "구현 계획 작성", + "executing_plans": "구현 계획 실행", + "systematic_debugging": "체계적 디버깅", + "test_driven_development": "TDD — 테스트 주도 개발", + "requesting_code_review": "코드 리뷰 요청", + "receiving_code_review": "코드 리뷰 피드백 적용", + "dispatching_parallel_agents": "병렬 에이전트 분산", + "using_git_worktrees": "Git worktree 격리 작업", + "finishing_a_development_branch": "개발 브랜치 마무리", + "using_superpowers": "Superpowers 스킬 안내", + "verification_before_completion": "완료 전 검증", + "writing_skills": "새 스킬 작성", + "subagent_driven_development": "서브에이전트 기반 개발", + # nestjs-hexagonal + "domain": "NestJS 도메인 레이어 (Entity, VO, Event)", + "application": "NestJS 애플리케이션 레이어 (UseCase, CQRS)", + "infrastructure": "NestJS 인프라 레이어 (Repository, Module)", + "presentation": "NestJS 프레젠테이션 레이어 (Controller, DTO)", + "create_subdomain": "NestJS 바운디드 컨텍스트 생성", + "event_listeners": "NestJS 도메인 이벤트 리스너", + "review_subdomain": "NestJS 헥사고날 아키텍처 리뷰", + "using_nestjs_hexagonal": "NestJS 헥사고날 라우터", + "websocket_broadcasting": "WebSocket 브로드캐스팅", + "gsd_installer": "GSD 워크플로 설정", + # figma + "figma_use": "Figma 파일 읽기 (필수 전처리)", + "figma_implement_design": "Figma → 코드 구현", + "figma_generate_design": "코드 → Figma 디자인 생성", + "figma_generate_library": "Figma 디자인 시스템 빌드", + "figma_code_connect": "Figma Code Connect 매핑", + "figma_create_new_file": "Figma 새 파일 생성", + "figma_create_design_system": "Figma 디자인 시스템 규칙 생성", + # frontend-design + "frontend_design": "프론트엔드 UI 디자인 구현", + # octo + "skill_debug": "Octo 디버깅", + "skill_tdd": "Octo TDD", + "skill_code_review": "Octo 코드 리뷰", + "skill_prd": "Octo PRD 작성", + "skill_audit": "Octo 보안 감사", + "skill_deck": "Octo 슬라이드 덱 생성", + "skill_parallel_agents": "Octo 병렬 에이전트", + "octopus_quick": "Octo 빠른 실행", + "octopus_quick_review": "Octo 빠른 리뷰", + "octopus_architecture": "Octo 아키텍처 설계", + "octopus_security_audit": "Octo 보안 감사", + "flow_discover": "Octo 발견 단계", + "flow_define": "Octo 정의 단계", + "flow_develop": "Octo 개발 단계", + "flow_deliver": "Octo 배포 단계", + "flow_spec": "Octo 스펙 작성", + "skill_debate": "Octo AI 토론", + # pr-review-toolkit + "skill_staged_review": "단계별 코드 리뷰", + "skill_finish_branch": "브랜치 마무리", + "skill_validate": "스킬 검증", +} + def _build_bot_commands() -> list[BotCommand]: """Build the full list of bot commands: built-in + CC + skills.""" 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"), - BotCommand("favorite", "Toggle skill favorites"), + BotCommand("start", "환영 메시지"), + BotCommand("history", "메시지 히스토리"), + BotCommand("screenshot", "터미널 스크린샷"), + BotCommand("esc", "Claude 인터럽트 (Escape)"), + BotCommand("kill", "세션 종료 + 토픽 삭제"), + BotCommand("unbind", "토픽-세션 바인딩 해제"), + BotCommand("usage", "Claude Code 사용량 확인"), + BotCommand("favorite", "스킬 즐겨찾기 토글"), ] for cmd_name, desc in CC_COMMANDS.items(): commands.append(BotCommand(cmd_name, desc)) @@ -184,7 +246,11 @@ def _build_bot_commands() -> list[BotCommand]: skills = _skill_registry.get_sorted_skills()[:remaining] max_desc = max(10, desc_budget // len(skills)) if skills else 0 for skill in skills: - desc = f"↗ {skill.description}"[:max_desc] + ko = _SKILL_DESC_KO.get(skill.command) + if ko: + desc = f"↗ {ko}"[:max_desc] + else: + desc = f"↗ {skill.description}"[:max_desc] commands.append(BotCommand(skill.command, desc)) return commands diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index a2757529..2b663bf0 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -61,29 +61,29 @@ def scan(self) -> list[SkillInfo]: # Collect raw skill entries: (plugin_name, skill_name, description) raw: list[tuple[str, str, str]] = [] - for marketplace_dir in self._plugins_dir.iterdir(): + for marketplace_dir in sorted(self._plugins_dir.iterdir()): if not marketplace_dir.is_dir(): continue - for plugin_dir in marketplace_dir.iterdir(): + for plugin_dir in sorted(marketplace_dir.iterdir()): if not plugin_dir.is_dir(): continue plugin_name = plugin_dir.name - # Find the version directory (take the first one) - for version_dir in plugin_dir.iterdir(): - if not version_dir.is_dir(): + # Pick the latest version directory only + version_dir = self._latest_version_dir(plugin_dir) + if not version_dir: + continue + skills_dir = version_dir / "skills" + if not skills_dir.is_dir(): + continue + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): continue - skills_dir = version_dir / "skills" - if not skills_dir.is_dir(): + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): continue - for skill_dir in skills_dir.iterdir(): - if not skill_dir.is_dir(): - continue - skill_md = skill_dir / "SKILL.md" - if not skill_md.is_file(): - continue - name, description = self._parse_skill_md(skill_md) - if name and description: - raw.append((plugin_name, name, description)) + name, description = self._parse_skill_md(skill_md) + if name and description: + raw.append((plugin_name, name, description)) # Convert to commands and detect collisions command_map: dict[str, list[tuple[str, str, str]]] = {} @@ -123,6 +123,27 @@ def scan(self) -> list[SkillInfo]: logger.info("Scanned %d skills from %s", len(skills), self._plugins_dir) return list(skills.values()) + @staticmethod + def _latest_version_dir(plugin_dir: Path) -> Path | None: + """Pick the latest version directory from a plugin package. + + Tries semver sorting first, falls back to lexicographic. + Returns None if no valid directory found. + """ + candidates = [d for d in plugin_dir.iterdir() if d.is_dir()] + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + def version_key(d: Path) -> tuple[int, ...]: + try: + return tuple(int(x) for x in d.name.split(".")) + except ValueError: + return (0,) + + return max(candidates, key=version_key) + @staticmethod def _parse_skill_md(path: Path) -> tuple[str, str]: """Parse YAML frontmatter from SKILL.md for name and description.""" From 053227a32dc518e798d3fa5ab880819f1ad51fad Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 10:57:57 +0900 Subject: [PATCH 18/35] =?UTF-8?q?docs:=20update=20README=20=E2=80=94=20add?= =?UTF-8?q?=20missing=20commands,=20Korean=20skill=20descriptions,=20compl?= =?UTF-8?q?ete=20file=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fb76bf89..afc6f1b7 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,16 @@ uv run ccbot **Bot commands:** -| Command | Description | -| ------------- | ------------------------------- | -| `/start` | Show welcome message | -| `/history` | Message history for this topic | -| `/screenshot` | Capture terminal screenshot | -| `/esc` | Send Escape to interrupt Claude | -| `/favorite` | Toggle skill favorites | +| Command | Description | +| ------------- | ---------------------------------- | +| `/start` | Show welcome message | +| `/history` | Message history for this topic | +| `/screenshot` | Capture terminal screenshot | +| `/esc` | Send Escape to interrupt Claude | +| `/kill` | Kill session and delete topic | +| `/unbind` | Unbind topic from session | +| `/usage` | Show Claude Code usage remaining | +| `/favorite` | Toggle skill favorites | **Claude Code commands (forwarded via tmux):** @@ -165,20 +168,22 @@ uv run ccbot | `/cost` | Show token/cost usage | | `/help` | Show Claude Code help | | `/memory` | Edit CLAUDE.md | +| `/model` | Switch AI model | Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/review`, `/doctor`, `/init`). **Plugin skills (auto-discovered):** -Installed Claude Code plugins are automatically scanned at startup. Their skills appear in the Telegram `/` command menu alongside built-in commands. For example, if you have `superpowers` installed: +Installed Claude Code plugins are automatically scanned at startup. Their skills appear in the Telegram `/` command menu alongside built-in commands. Skills with Korean translations show localized descriptions. For example: -| Command | Description | -| -------------------- | ----------------------------------------- | -| `/brainstorming` | ↗ Design features through collaborative dialogue | -| `/systematic_debugging` | ↗ Debug issues systematically | -| `/writing_plans` | ↗ Write implementation plans | -| `/test_driven_development` | ↗ TDD workflow | -| ... | (all installed plugin skills) | +| Command | Description | +| -------------------------- | ------------------------------ | +| `/brainstorming` | ↗ 브레인스토밍 — 기능 설계 전 아이디어 구체화 | +| `/systematic_debugging` | ↗ 체계적 디버깅 | +| `/writing_plans` | ↗ 구현 계획 작성 | +| `/test_driven_development` | ↗ TDD — 테스트 주도 개발 | +| `/skill_debug` | ↗ Octo 디버깅 | +| ... | (all installed plugin skills) | Use `/favorite` to pin your most-used skills to the top of the menu. @@ -282,8 +287,10 @@ src/ccbot/ ├── screenshot.py # Terminal text → PNG image with ANSI color support ├── transcribe.py # Voice-to-text transcription via OpenAI API ├── skill_registry.py # Plugin skill discovery and Telegram command registration +├── message_batcher.py # Batch tool_use/thinking messages into summaries ├── utils.py # Shared utilities (atomic JSON writes, JSONL helpers) ├── tmux_manager.py # Tmux window management (list, create, send keys, kill) +├── telegram_sender.py # Telegram message splitting (4096 char limit) ├── fonts/ # Bundled fonts for screenshot rendering └── handlers/ ├── __init__.py # Handler module exports From 2f6b5b2eae6852c84dfb73880cb275dea00ede01 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 11:26:51 +0900 Subject: [PATCH 19/35] docs: message queue ordering design spec --- ...026-04-09-message-queue-ordering-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/specs/2026-04-09-message-queue-ordering-design.md diff --git a/docs/specs/2026-04-09-message-queue-ordering-design.md b/docs/specs/2026-04-09-message-queue-ordering-design.md new file mode 100644 index 00000000..423398f2 --- /dev/null +++ b/docs/specs/2026-04-09-message-queue-ordering-design.md @@ -0,0 +1,96 @@ +# 메시지 큐 순서 보장 설계 + +> Claude 응답과 시간적으로 겹칠 수 있는 직접 전송 메시지를 기존 FIFO 큐로 통일하여 텔레그램 메시지 순서를 보장한다. + +## 배경 + +ccbot의 메시지 전송 경로가 2개로 나뉘어 있음: +- **큐 경로**: JSONL 모니터 → `enqueue_content_message` → FIFO 큐 워커 → Telegram +- **직접 경로**: `safe_reply()` 등으로 즉시 전송 (큐 우회) + +이 두 경로가 동시에 같은 토픽에 메시지를 보내면 Telegram 서버 도착 순서가 엇갈림. 대표적으로 `⚡ Sent: /brainstorming` 확인 메시지가 Claude 응답 사이에 끼어드는 문제. + +## 핵심 변경 + +`message_queue.py`에 `DirectMessage` 타입과 `enqueue_direct_message()` 함수를 추가. 큐 워커가 기존 ContentMessage/StatusUpdate와 동일한 FIFO 순서로 DirectMessage도 처리. + +## DirectMessage 타입 + +```python +@dataclass +class DirectMessage: + chat_id: int + thread_id: int | None + text: str + parse_mode: str | None = None + reply_markup: InlineKeyboardMarkup | None = None +``` + +- merging 없음 (독립 메시지) +- `send_with_fallback`으로 전송 +- `reply_markup` 지원으로 Interactive UI 메시지도 처리 가능 + +## enqueue_direct_message API + +```python +async def enqueue_direct_message( + user_id: int, + chat_id: int, + thread_id: int | None, + text: str, + parse_mode: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, +) -> None: +``` + +- `chat_id`/`thread_id`를 명시적으로 받음 — 큐 워커는 나중에 실행되므로 호출 시점에 추출 +- 큐 워커가 없으면 자동 시작 (기존 `enqueue_content_message` 패턴) + +## 큐 워커 변경 + +`_message_queue_worker`의 처리 루프에 DirectMessage 분기 추가: + +```python +item = await queue.get() +if isinstance(item, DirectMessage): + await send_with_fallback(bot, item.chat_id, item.text, + thread_id=item.thread_id, + parse_mode=item.parse_mode, + reply_markup=item.reply_markup) +elif isinstance(item, ContentMessage): + # 기존 로직 +elif isinstance(item, StatusUpdate): + # 기존 로직 +``` + +## 큐로 전환할 전송 경로 + +| 전송 | 파일:위치 | 현재 | 변경 | +|------|-----------|------|------| +| `⚡ Sent: /command` | bot.py forward_command_handler | `safe_reply()` | `enqueue_direct_message()` | +| `📷 Image sent` | bot.py photo_handler | `safe_reply()` | `enqueue_direct_message()` | +| `🎙 Voice forwarded` | bot.py voice_handler | `safe_reply()` | `enqueue_direct_message()` | +| Interactive UI 전송 | interactive_ui.py handle_interactive_ui | `bot.send_message()` | `enqueue_direct_message()` | +| Bash capture 출력 | bot.py _send_bash_capture | `send_with_fallback()` | `enqueue_direct_message()` | + +## 직접 전송 유지 + +| 전송 | 이유 | +|------|------| +| `❌` 에러 메시지 | 즉시 피드백 필요 | +| 디렉토리 브라우저 / 세션 피커 | 인터랙티브 UI, 큐 지연이 UX 해침 | +| 콜백 쿼리 응답 (query.answer) | Telegram이 빠른 응답 요구 | +| /history, /screenshot 표시 | 사용자 요청에 대한 즉시 응답 | +| /favorite 키보드 | 동일 | +| /start 환영 메시지 | 동일 | + +## 판단 기준 + +- **큐**: "이 메시지가 Claude 응답 사이에 끼어들 수 있는가?" → Yes → 큐 +- **직접**: "즉시 반응이 필수" 또는 "Claude 비작업 상태에서만 발생" → 직접 + +## 스코프 외 + +- 큐 성능 최적화 +- 큐 full 시 backpressure +- edit 메시지 순서 보장 (edit는 기존 메시지 수정이므로 순서 무관) From 0aa3687075933ea0317b8fb1093ecf266e5b2a82 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 11:33:28 +0900 Subject: [PATCH 20/35] docs: move spec to docs/ directory --- ...026-04-09-message-queue-ordering-design.md | 0 docs/specs/2026-04-08-skill-menu-design.md | 179 ++++++++++++++++++ 2 files changed, 179 insertions(+) rename docs/{specs => }/2026-04-09-message-queue-ordering-design.md (100%) create mode 100644 docs/specs/2026-04-08-skill-menu-design.md diff --git a/docs/specs/2026-04-09-message-queue-ordering-design.md b/docs/2026-04-09-message-queue-ordering-design.md similarity index 100% rename from docs/specs/2026-04-09-message-queue-ordering-design.md rename to docs/2026-04-09-message-queue-ordering-design.md diff --git a/docs/specs/2026-04-08-skill-menu-design.md b/docs/specs/2026-04-08-skill-menu-design.md new file mode 100644 index 00000000..9559f081 --- /dev/null +++ b/docs/specs/2026-04-08-skill-menu-design.md @@ -0,0 +1,179 @@ +# ccbot 스킬 메뉴 설계 + +> 텔레그램 `/` 커맨드 메뉴에 설치된 Claude Code 스킬을 자동 등록하여, 텔레그램에서 바로 스킬을 실행할 수 있게 한다. + +## 배경 + +현재 ccbot은 `/clear`, `/compact` 같은 기본 Claude Code 명령만 텔레그램에서 사용 가능하다. superpowers, pr-review-toolkit 등 설치된 플러그인 스킬은 텔레그램에서 목록 조회나 실행이 불가능하다. + +## 핵심 동작 + +``` +ccbot 시작 + → ~/.claude/plugins/cache/ 스캔 + → SKILL.md에서 name, description 파싱 + → bot.set_my_commands()로 텔레그램 커맨드 등록 + +사용자가 / 입력 + → 기존 봇 명령 + 설치된 스킬 목록 표시 + → 즐겨찾기 상단, 그 다음 사용빈도순 + +사용자가 /brainstorming 탭 + → forward_command_handler로 전달 + → tmux send-keys "/brainstorming" Enter + → Claude가 스킬 실행 +``` + +## 모듈 설계 + +### skill_registry.py (신규) + +스킬 스캔, 캐싱, 사용 통계, 즐겨찾기를 전담하는 모듈. + +#### 클래스: SkillRegistry + +```python +class SkillInfo: + name: str # 원본 스킬 이름 (e.g. "brainstorming") + command: str # 텔레그램 커맨드 (e.g. "brainstorming") + description: str # SKILL.md에서 파싱한 설명 + plugin: str # 소속 플러그인 (e.g. "superpowers") + slash_command: str # Claude에 전달할 원본 (e.g. "/brainstorming") + +class SkillRegistry: + def __init__(self, plugins_dir: str, state_path: str): ... + def scan(self) -> list[SkillInfo]: ... + def get_sorted_commands(self, project_dir: str | None) -> list[BotCommand]: ... + def record_usage(self, command: str, project_dir: str) -> None: ... + def toggle_favorite(self, command: str) -> bool: ... + def is_favorite(self, command: str) -> bool: ... + def get_favorites(self) -> list[str]: ... +``` + +#### 스캔 대상 + +``` +~/.claude/plugins/cache/ +├── claude-plugins-official/ +│ ├── superpowers/5.0.7/skills/ → brainstorming, debug, tdd, ... +│ │ └── */SKILL.md → name, description 파싱 +│ ├── superpowers/5.0.7/commands/ → deprecated, 스캔 제외 +│ └── pr-review-toolkit/*/skills/ → code-reviewer, ... +│ └── */SKILL.md +├── claude-community/ +├── imgompanda/ +└── ... +``` + +#### SKILL.md 파싱 규칙 + +```yaml +--- +name: brainstorming +description: "You MUST use this before any creative work..." +--- +``` + +- `name` → 커맨드 이름으로 사용 +- `description` → 첫 문장만 추출하여 텔레그램 커맨드 설명 (256자 제한) +- 하이픈 → 언더스코어 변환 (`review-pr` → `review_pr`) +- 이름 충돌 시 플러그인 접두사 (`sp_brainstorming`) + +### 커맨드 이름 매핑 + +| 원본 스킬 이름 | 텔레그램 커맨드 | Claude 전달 | +|---------------|---------------|------------| +| `superpowers:brainstorming` | `/brainstorming` | `/brainstorming` | +| `superpowers:systematic-debugging` | `/systematic_debugging` | `/systematic-debugging` | +| `pr-review-toolkit:code-reviewer` | `/pr_code_reviewer` | `/code-reviewer` | +| `superpowers:writing-plans` | `/writing_plans` | `/writing-plans` | + +플러그인 접두사 생략이 기본이며, 이름 충돌 시에만 접두사를 붙인다. + +### 커맨드 등록 순서 + +`bot.set_my_commands()`에 전달할 순서: + +1. **기존 봇 명령** — `start`, `history`, `screenshot`, `esc`, `unbind`, `usage` +2. **즐겨찾기 스킬** — `skill_state.json`의 favorites 순서 +3. **현재 프로젝트 사용빈도순** — 해당 프로젝트 디렉토리에서 많이 쓴 순 +4. **나머지** — 알파벳순 + +### 상태 파일 + +```json +// ~/.ccbot/skill_state.json +{ + "favorites": ["commit", "brainstorming"], + "usage": { + "/Users/pakjungeol/Documents/Insudeal/CeoReport": { + "commit": 15, + "review_pr": 8 + }, + "/Users/pakjungeol/Documents/Claude": { + "brainstorming": 12, + "writing_plans": 5 + } + } +} +``` + +## bot.py 변경 + +### post_init + +```python +async def post_init(app: Application) -> None: + # ... 기존 초기화 ... + skill_registry.scan() + commands = skill_registry.get_sorted_commands(project_dir=None) + await app.bot.set_my_commands(commands) +``` + +### forward_command_handler 확장 + +스킬 커맨드 실행 시 usage 기록: + +```python +async def forward_command_handler(update, context): + # ... 기존 로직 ... + cmd = cc_slash.lstrip("/") + if skill_registry.is_skill(cmd): + project_dir = session_manager.get_project_dir(wid) + skill_registry.record_usage(cmd, project_dir) + # 커맨드→원본 스킬명 변환하여 전달 + cc_slash = skill_registry.get_slash_command(cmd) + # ... tmux send-keys ... +``` + +### /favorite 명령 + +```python +async def favorite_command(update, context): + skills = skill_registry.get_all_skills() + keyboard = [] + for skill in skills: + star = "⭐ " if skill_registry.is_favorite(skill.command) else "" + keyboard.append([InlineKeyboardButton( + f"{star}{skill.command} — {skill.description[:40]}", + callback_data=f"fav:{skill.command}" + )]) + await safe_reply(update.message, "즐겨찾기 토글:", reply_markup=InlineKeyboardMarkup(keyboard)) +``` + +즐겨찾기 변경 시 `bot.set_my_commands()`를 다시 호출하여 순서 갱신. + +## 커맨드 메뉴 갱신 타이밍 + +| 이벤트 | 동작 | +|--------|------| +| ccbot 시작 | 전체 스캔 + 커맨드 등록 | +| 즐겨찾기 토글 | 커맨드 순서 재등록 | +| 세션 바인딩 변경 | 해당 프로젝트 사용빈도 반영하여 재등록 | + +## 스코프 외 (향후) + +- 매크로/조합 스킬 +- 스킬 파라미터 입력 UI +- 프로젝트별 커맨드 메뉴 자동 전환 (토픽 진입 시) +- 커스텀 스킬 생성 (`~/.ccbot/custom_skills/`) From 6139e8ff0c0aafd2d7159987de9cea6b52dd1477 Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 11:35:32 +0900 Subject: [PATCH 21/35] docs: message queue ordering implementation plan --- plans/2026-04-09-message-queue-ordering.md | 439 +++++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 plans/2026-04-09-message-queue-ordering.md diff --git a/plans/2026-04-09-message-queue-ordering.md b/plans/2026-04-09-message-queue-ordering.md new file mode 100644 index 00000000..a303be6e --- /dev/null +++ b/plans/2026-04-09-message-queue-ordering.md @@ -0,0 +1,439 @@ +# 메시지 큐 순서 보장 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Claude 응답과 시간적으로 겹칠 수 있는 직접 전송 메시지를 기존 FIFO 큐로 통일하여 텔레그램 메시지 순서를 보장한다. + +**Architecture:** `message_queue.py`에 `DirectMessage` 타입과 `enqueue_direct_message()` 함수를 추가. 큐 워커의 FIFO 루프에 `direct` 분기를 추가하여 기존 content/status와 동일한 순서 보장. bot.py의 해당 `safe_reply()` 호출들을 `enqueue_direct_message()`로 교체. + +**Tech Stack:** Python 3.12, python-telegram-bot, asyncio + +**Spec:** `docs/2026-04-09-message-queue-ordering-design.md` + +--- + +### Task 1: DirectMessage 타입 및 enqueue_direct_message 추가 + +**Files:** +- Modify: `src/ccbot/handlers/message_queue.py` +- Test: `tests/ccbot/test_message_queue_direct.py` + +- [ ] **Step 1: 테스트 파일 생성** + +```python +# tests/ccbot/test_message_queue_direct.py +"""Tests for DirectMessage queue type.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers.message_queue import ( + DirectMessage, + MessageTask, + enqueue_direct_message, + get_or_create_queue, +) + + +def test_direct_message_dataclass() -> None: + """DirectMessage has required fields with defaults.""" + msg = DirectMessage(chat_id=123, thread_id=42, text="hello") + assert msg.chat_id == 123 + assert msg.thread_id == 42 + assert msg.text == "hello" + assert msg.parse_mode is None + assert msg.reply_markup is None + + +def test_direct_message_with_parse_mode() -> None: + msg = DirectMessage(chat_id=123, thread_id=None, text="test", parse_mode="HTML") + assert msg.parse_mode == "HTML" + + +@pytest.fixture +def mock_bot() -> MagicMock: + bot = MagicMock() + bot.send_message = AsyncMock() + return bot + + +async def test_enqueue_direct_creates_queue(mock_bot: MagicMock) -> None: + """enqueue_direct_message creates queue and worker if not exists.""" + with patch( + "ccbot.handlers.message_queue.get_or_create_queue" + ) as mock_get: + mock_queue = asyncio.Queue() + mock_get.return_value = mock_queue + + await enqueue_direct_message( + bot=mock_bot, + user_id=999, + chat_id=123, + thread_id=42, + text="test message", + ) + + mock_get.assert_called_once_with(mock_bot, 999) + assert not mock_queue.empty() + item = mock_queue.get_nowait() + assert isinstance(item, DirectMessage) + assert item.text == "test message" + assert item.chat_id == 123 + assert item.thread_id == 42 +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ccbot/test_message_queue_direct.py -v` +Expected: FAIL — `ImportError: cannot import name 'DirectMessage'` + +- [ ] **Step 3: DirectMessage 타입 추가** + +`src/ccbot/handlers/message_queue.py`에서 `MessageTask` dataclass 바로 아래 (line 67 이후)에 추가: + +```python +@dataclass +class DirectMessage: + """Direct message to send through the queue for ordering guarantees. + + Unlike ContentMessage (from JSONL monitor) and StatusUpdate (from polling), + this represents messages that were previously sent via safe_reply() directly, + bypassing the queue. Routing them through the queue ensures they appear + in correct order relative to Claude's responses. + """ + + chat_id: int + thread_id: int | None = None + text: str = "" + parse_mode: str | None = None + reply_markup: object | None = None # InlineKeyboardMarkup +``` + +- [ ] **Step 4: enqueue_direct_message 함수 추가** + +`src/ccbot/handlers/message_queue.py`의 `enqueue_status_update` 함수 바로 아래에 추가: + +```python +async def enqueue_direct_message( + bot: Bot, + user_id: int, + chat_id: int, + thread_id: int | None, + text: str, + parse_mode: str | None = None, + reply_markup: object | None = None, +) -> None: + """Enqueue a direct message for ordered delivery. + + Use this instead of safe_reply() for messages that may interleave + with Claude responses (command confirmations, photo/voice acks, etc.). + """ + queue = get_or_create_queue(bot, user_id) + msg = DirectMessage( + chat_id=chat_id, + thread_id=thread_id, + text=text, + parse_mode=parse_mode, + reply_markup=reply_markup, + ) + queue.put_nowait(msg) +``` + +- [ ] **Step 5: 큐 워커에 DirectMessage 처리 분기 추가** + +`_message_queue_worker` 함수 (line ~200)의 `task = await queue.get()` 이후, `if task.task_type == "content":` 분기 앞에 DirectMessage 처리를 추가: + +```python + if isinstance(task, DirectMessage): + await _process_direct_message(bot, user_id, task) + elif task.task_type == "content": +``` + +그리고 `_process_direct_message` 함수 추가 (`_process_content_task` 앞): + +```python +async def _process_direct_message( + bot: Bot, user_id: int, msg: DirectMessage +) -> None: + """Send a direct message through the queue.""" + kwargs = _send_kwargs(msg.thread_id) + if msg.reply_markup: + kwargs["reply_markup"] = msg.reply_markup + try: + if msg.parse_mode: + await bot.send_message( + chat_id=msg.chat_id, + text=msg.text, + parse_mode=msg.parse_mode, + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + else: + await bot.send_message( + chat_id=msg.chat_id, + text=msg.text, + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + except Exception: + # Fallback: try plain text without parse_mode + try: + await bot.send_message( + chat_id=msg.chat_id, + text=strip_sentinels(msg.text), + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + except Exception as e: + logger.error("Failed to send direct message: %s", e) +``` + +- [ ] **Step 6: `__init__.py` export 업데이트 (필요 시)** + +`message_queue.py`에서 이미 `enqueue_content_message`과 `enqueue_status_update`가 bot.py에서 직접 import되고 있으므로, 동일 패턴으로 `enqueue_direct_message`와 `DirectMessage`도 import하면 됨. 별도 `__init__.py` 변경 불필요. + +- [ ] **Step 7: 테스트 실행 — 통과 확인** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ccbot/test_message_queue_direct.py -v` +Expected: 모든 테스트 PASS + +- [ ] **Step 8: 린트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/handlers/message_queue.py tests/ccbot/test_message_queue_direct.py && uv run ruff format --check src/ccbot/handlers/message_queue.py tests/ccbot/test_message_queue_direct.py` +Expected: 에러 없음 + +- [ ] **Step 9: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/handlers/message_queue.py tests/ccbot/test_message_queue_direct.py +git commit -m "feat: add DirectMessage type and enqueue_direct_message for ordering" +``` + +--- + +### Task 2: forward_command_handler를 큐로 전환 + +**Files:** +- Modify: `src/ccbot/bot.py` + +- [ ] **Step 1: import 추가** + +`bot.py` 상단 import 블록에서 기존 `message_queue` import에 `enqueue_direct_message` 추가: + +```python +from .handlers.message_queue import ( + clear_status_msg_info, + enqueue_content_message, + enqueue_direct_message, # 추가 + enqueue_status_update, + get_message_queue, + shutdown_workers, +) +``` + +- [ ] **Step 2: forward_command_handler 성공 경로 변경** + +`forward_command_handler`에서 성공 시 `safe_reply` (현재 `⚡ [{display}] Sent: {cc_slash}`) 를 `enqueue_direct_message`로 교체. + +현재 코드 (bot.py의 forward_command_handler, 성공 분기): +```python + if success: + await safe_reply(update.message, f"⚡ [{display}] Sent: {cc_slash}") +``` + +변경: +```python + if success: + chat = update.effective_chat + chat_id = chat.id if chat else user.id + await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text=f"⚡ [{display}] Sent: {cc_slash}", + ) +``` + +실패 경로 (`❌`)는 즉시 피드백이 필요하므로 `safe_reply` 유지. + +- [ ] **Step 3: 린트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/bot.py` +Expected: 에러 없음 + +- [ ] **Step 4: 전체 테스트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ -v --tb=short` +Expected: 모든 테스트 PASS + +- [ ] **Step 5: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/bot.py +git commit -m "feat: route forward_command_handler confirmations through message queue" +``` + +--- + +### Task 3: photo/voice 확인 메시지를 큐로 전환 + +**Files:** +- Modify: `src/ccbot/bot.py` + +- [ ] **Step 1: photo_handler 변경** + +사진 전달 성공 후 확인 메시지를 큐로: + +현재: +```python +await safe_reply(update.message, f"📷 Image sent to {display}") +``` + +변경: +```python +chat = update.effective_chat +chat_id = chat.id if chat else user.id +await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text=f"📷 Image sent to {display}", +) +``` + +실패/에러 경로는 `safe_reply` 유지. + +- [ ] **Step 2: voice_handler 변경** + +음성 전사 후 전달 확인 메시지를 큐로: + +현재: +```python +await safe_reply(update.message, f"🎙 Voice forwarded to {display}: {transcript[:100]}") +``` + +변경: +```python +chat = update.effective_chat +chat_id = chat.id if chat else user.id +await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text=f"🎙 Voice forwarded to {display}: {transcript[:100]}", +) +``` + +- [ ] **Step 3: 린트 및 테스트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/bot.py && uv run pytest tests/ -v --tb=short` +Expected: 에러 없음, 모든 테스트 PASS + +- [ ] **Step 4: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/bot.py +git commit -m "feat: route photo/voice confirmations through message queue" +``` + +--- + +### Task 4: Interactive UI 메시지를 큐로 전환 + +**Files:** +- Modify: `src/ccbot/handlers/interactive_ui.py` + +- [ ] **Step 1: Interactive UI의 새 메시지 전송을 큐로 전환** + +`handle_interactive_ui` 함수에서 새 Interactive UI 메시지를 `bot.send_message()`로 직접 보내는 부분을 `enqueue_direct_message`로 변경. + +현재 (`interactive_ui.py`의 새 메시지 전송 부분): +```python +msg = await bot.send_message( + chat_id=chat_id, + text=formatted, + parse_mode=PARSE_MODE, + reply_markup=keyboard, + message_thread_id=thread_id, +) +``` + +변경: +```python +from .message_queue import enqueue_direct_message + +await enqueue_direct_message( + bot=bot, + user_id=user_id, + chat_id=chat_id, + thread_id=thread_id, + text=formatted, + parse_mode=PARSE_MODE, + reply_markup=keyboard, +) +``` + +주의: 기존 코드는 `send_message`의 반환값으로 `msg.message_id`를 저장하여 나중에 edit할 때 사용. `enqueue_direct_message`는 반환값이 없으므로, Interactive UI의 경우 **edit가 필요한 메시지는 직접 전송을 유지**하고, 새 메시지 전송만 큐로 돌리는 것이 적절. + +실제로 `handle_interactive_ui`에서 `set_interactive_msg(user_id, msg.message_id)`를 호출하므로, message_id 추적이 필요한 경우는 직접 전송 유지가 맞음. + +**수정**: Interactive UI는 message_id 추적이 필수이므로 **직접 전송 유지**. 이 Task는 스킵. + +- [ ] **Step 2: 커밋 (변경 없음 → 스킵)** + +--- + +### Task 5: Bash capture 출력을 큐로 전환 + +**Files:** +- Modify: `src/ccbot/bot.py` + +- [ ] **Step 1: bash capture 첫 전송을 큐로 전환** + +`_send_bash_capture` (또는 해당 함수)에서 `send_with_fallback()`로 직접 보내는 부분을 `enqueue_direct_message`로 변경. + +먼저 정확한 함수명과 위치를 확인하여 변경. bash capture는 background task에서 실행되므로, `bot` 인스턴스와 `user_id`를 전달받는 구조인지 확인 필요. + +bash capture에서 `send_with_fallback`로 보내는 **첫 메시지**는 `enqueue_direct_message`로 변경. 이후 **edit** (`bot.edit_message_text`)는 기존 메시지를 수정하는 것이므로 순서 무관 — 직접 유지. + +주의: bash capture도 message_id를 저장하여 후속 edit에 사용. 첫 전송을 큐로 넣으면 message_id를 받을 수 없음. + +**수정**: Bash capture도 message_id 추적이 필수이므로 **직접 전송 유지**. 이 Task는 스킵. + +--- + +### Task 6: 전체 테스트 및 수동 검증 + +**Files:** +- Test: manual verification + +- [ ] **Step 1: 전체 테스트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ -v` +Expected: 모든 테스트 PASS + +- [ ] **Step 2: 린트 + 타입체크** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/` +Expected: 에러 없음 + +- [ ] **Step 3: ccbot 재시작 및 수동 검증** + +검증 항목: +1. 텔레그램에서 `/brainstorming` 실행 → `⚡ Sent:` 메시지가 Claude 응답 이전에 순서대로 표시되는지 +2. 사진 전송 → `📷` 확인 메시지가 Claude 응답과 올바른 순서로 표시되는지 +3. 음성 전송 → `🎙` 확인 메시지 순서 확인 +4. 에러 메시지 (`❌`)는 여전히 즉시 표시되는지 +5. 디렉토리 브라우저, /history, /screenshot 등은 여전히 즉시 반응하는지 + +- [ ] **Step 4: 커밋 (필요 시)** + +수동 검증 중 발견된 수정사항이 있으면 커밋. From 44462a350f410a221b36adf1d52665c6df16e8ba Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 11:45:30 +0900 Subject: [PATCH 22/35] feat: add DirectMessage type and enqueue_direct_message for ordering Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/handlers/message_queue.py | 99 ++++++++++++++++++++++-- tests/ccbot/test_message_queue_direct.py | 86 ++++++++++++++++++++ 2 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 tests/ccbot/test_message_queue_direct.py diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index e83a0612..4a7c83fd 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -66,8 +66,25 @@ class MessageTask: image_data: list[tuple[str, bytes]] | None = None # From tool_result images +@dataclass +class DirectMessage: + """Direct message to send through the queue for ordering guarantees. + + Unlike ContentMessage (from JSONL monitor) and StatusUpdate (from polling), + this represents messages that were previously sent via safe_reply() directly, + bypassing the queue. Routing them through the queue ensures they appear + in correct order relative to Claude's responses. + """ + + chat_id: int + thread_id: int | None = None + text: str = "" + parse_mode: str | None = None + reply_markup: object | None = None # InlineKeyboardMarkup + + # Per-user message queues and worker tasks -_message_queues: dict[int, asyncio.Queue[MessageTask]] = {} +_message_queues: dict[int, asyncio.Queue[MessageTask | DirectMessage]] = {} _queue_workers: dict[int, asyncio.Task[None]] = {} _queue_locks: dict[int, asyncio.Lock] = {} # Protect drain/refill operations @@ -85,12 +102,16 @@ class MessageTask: FLOOD_CONTROL_MAX_WAIT = 10 -def get_message_queue(user_id: int) -> asyncio.Queue[MessageTask] | None: +def get_message_queue( + user_id: int, +) -> asyncio.Queue[MessageTask | DirectMessage] | None: """Get the message queue for a user (if exists).""" return _message_queues.get(user_id) -def get_or_create_queue(bot: Bot, user_id: int) -> asyncio.Queue[MessageTask]: +def get_or_create_queue( + bot: Bot, user_id: int +) -> asyncio.Queue[MessageTask | DirectMessage]: """Get or create message queue and worker for a user.""" if user_id not in _message_queues: _message_queues[user_id] = asyncio.Queue() @@ -207,15 +228,18 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: try: task = await queue.get() try: - # Flood control: drop status, wait for content + # Flood control: drop status, wait for content/direct flood_end = _flood_until.get(user_id, 0) if flood_end > 0: remaining = flood_end - time.monotonic() if remaining > 0: - if task.task_type != "content": + if ( + isinstance(task, MessageTask) + and task.task_type != "content" + ): # Status is ephemeral — safe to drop continue - # Content is actual Claude output — wait then send + # Content/direct is actual output — wait then send logger.debug( "Flood controlled: waiting %.0fs for content (user %d)", remaining, @@ -226,7 +250,9 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: _flood_until.pop(user_id, None) logger.info("Flood control lifted for user %d", user_id) - if task.task_type == "content": + if isinstance(task, DirectMessage): + await _process_direct_message(bot, user_id, task) + elif task.task_type == "content": # Try to merge consecutive content tasks merged_task, merge_count = await _merge_content_tasks( queue, task, lock @@ -297,6 +323,40 @@ async def _send_task_images(bot: Bot, chat_id: int, task: MessageTask) -> None: ) +async def _process_direct_message(bot: Bot, user_id: int, msg: DirectMessage) -> None: + """Send a direct message through the queue.""" + kwargs = _send_kwargs(msg.thread_id) + if msg.reply_markup: + kwargs["reply_markup"] = msg.reply_markup + try: + if msg.parse_mode: + await bot.send_message( + chat_id=msg.chat_id, + text=msg.text, + parse_mode=msg.parse_mode, + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + else: + await bot.send_message( + chat_id=msg.chat_id, + text=msg.text, + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + except Exception: + # Fallback: try plain text without parse_mode + try: + await bot.send_message( + chat_id=msg.chat_id, + text=strip_sentinels(msg.text), + link_preview_options=NO_LINK_PREVIEW, + **kwargs, + ) + except Exception as e: + logger.error("Failed to send direct message: %s", e) + + async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> None: """Process a content message task.""" wid = task.window_id or "" @@ -664,6 +724,31 @@ async def enqueue_status_update( queue.put_nowait(task) +async def enqueue_direct_message( + bot: Bot, + user_id: int, + chat_id: int, + thread_id: int | None, + text: str, + parse_mode: str | None = None, + reply_markup: object | None = None, +) -> None: + """Enqueue a direct message for ordered delivery. + + Use this instead of safe_reply() for messages that may interleave + with Claude responses (command confirmations, photo/voice acks, etc.). + """ + queue = get_or_create_queue(bot, user_id) + msg = DirectMessage( + chat_id=chat_id, + thread_id=thread_id, + text=text, + parse_mode=parse_mode, + reply_markup=reply_markup, + ) + queue.put_nowait(msg) + + def clear_status_msg_info(user_id: int, thread_id: int | None = None) -> None: """Clear status message tracking for a user (and optionally a specific thread).""" skey = (user_id, thread_id or 0) diff --git a/tests/ccbot/test_message_queue_direct.py b/tests/ccbot/test_message_queue_direct.py new file mode 100644 index 00000000..ef144e0a --- /dev/null +++ b/tests/ccbot/test_message_queue_direct.py @@ -0,0 +1,86 @@ +"""Tests for DirectMessage type and enqueue_direct_message.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from ccbot.handlers.message_queue import ( + DirectMessage, + _message_queues, + _queue_locks, + _queue_workers, + enqueue_direct_message, +) + + +@pytest.fixture(autouse=True) +def _clean_queues(): + """Clean up global queue state before/after each test.""" + _message_queues.clear() + _queue_locks.clear() + for w in _queue_workers.values(): + w.cancel() + _queue_workers.clear() + yield + _message_queues.clear() + _queue_locks.clear() + for w in _queue_workers.values(): + w.cancel() + _queue_workers.clear() + + +class TestDirectMessageDataclass: + def test_direct_message_defaults(self): + msg = DirectMessage(chat_id=123) + assert msg.chat_id == 123 + assert msg.thread_id is None + assert msg.text == "" + assert msg.parse_mode is None + assert msg.reply_markup is None + + def test_direct_message_with_parse_mode(self): + markup = {"inline_keyboard": [[{"text": "OK"}]]} + msg = DirectMessage( + chat_id=123, + thread_id=42, + text="hello", + parse_mode="MarkdownV2", + reply_markup=markup, + ) + assert msg.chat_id == 123 + assert msg.thread_id == 42 + assert msg.text == "hello" + assert msg.parse_mode == "MarkdownV2" + assert msg.reply_markup == markup + + +class TestEnqueueDirectMessage: + @pytest.mark.asyncio + async def test_enqueue_direct_creates_queue(self): + bot = AsyncMock() + user_id = 999 + + with patch( + "ccbot.handlers.message_queue._message_queue_worker", + new_callable=AsyncMock, + ): + await enqueue_direct_message( + bot=bot, + user_id=user_id, + chat_id=123, + thread_id=42, + text="test message", + parse_mode="MarkdownV2", + ) + + assert user_id in _message_queues + queue = _message_queues[user_id] + assert not queue.empty() + + item = queue.get_nowait() + assert isinstance(item, DirectMessage) + assert item.chat_id == 123 + assert item.thread_id == 42 + assert item.text == "test message" + assert item.parse_mode == "MarkdownV2" + assert item.reply_markup is None From 6e79f8551402e28150a018a81fb369c995aa976d Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 11:47:16 +0900 Subject: [PATCH 23/35] feat: route command/photo/voice confirmations through message queue Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/bot.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 5a62d0a0..93423c3b 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -117,6 +117,7 @@ from .handlers.message_queue import ( clear_status_msg_info, enqueue_content_message, + enqueue_direct_message, enqueue_status_update, get_message_queue, shutdown_workers, @@ -676,7 +677,14 @@ async def forward_command_handler( await update.message.chat.send_action(ChatAction.TYPING) success, message = await session_manager.send_to_window(wid, cc_slash) if success: - await safe_reply(update.message, f"⚡ [{display}] Sent: {cc_slash}") + chat_id = chat.id if chat else user.id + await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text=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": @@ -781,7 +789,14 @@ 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.") + chat_id = chat.id if chat else user.id + await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text="📷 Image sent to Claude Code.", + ) async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -857,7 +872,14 @@ async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_reply(update.message, f"❌ {message}") return - await safe_reply(update.message, f'🎤 "{text}"') + chat_id = chat.id if chat else user.id + await enqueue_direct_message( + bot=context.bot, + user_id=user.id, + chat_id=chat_id, + thread_id=thread_id, + text=f'🎤 "{text}"', + ) # Active bash capture tasks: (user_id, thread_id) → asyncio.Task From 04878dc98308042a318bb9d6330dfc0b3f8dab8f Mon Sep 17 00:00:00 2001 From: TejNote Date: Thu, 9 Apr 2026 14:13:16 +0900 Subject: [PATCH 24/35] docs: add skill menu implementation plan --- plans/2026-04-08-skill-menu.md | 818 +++++++++++++++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 plans/2026-04-08-skill-menu.md diff --git a/plans/2026-04-08-skill-menu.md b/plans/2026-04-08-skill-menu.md new file mode 100644 index 00000000..6186e28c --- /dev/null +++ b/plans/2026-04-08-skill-menu.md @@ -0,0 +1,818 @@ +# ccbot 스킬 메뉴 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 텔레그램 `/` 커맨드 메뉴에 설치된 Claude Code 플러그인 스킬을 자동 등록하여 탭 한 번으로 실행할 수 있게 한다. + +**Architecture:** ccbot 시작 시 `~/.claude/plugins/cache/`를 스캔하여 SKILL.md에서 name/description을 파싱하고, 기존 봇 명령과 합쳐 `bot.set_my_commands()`로 등록한다. 즐겨찾기와 프로젝트별 사용빈도를 `skill_state.json`에 기록하여 커맨드 순서를 동적으로 정렬한다. + +**Tech Stack:** Python 3.12, python-telegram-bot, pathlib, PyYAML frontmatter 파싱 (직접 구현, 의존성 추가 없음) + +**Spec:** `docs/specs/2026-04-08-skill-menu-design.md` + +--- + +### Task 1: SkillRegistry 모듈 — 스킬 스캔 및 파싱 + +**Files:** +- Create: `src/ccbot/skill_registry.py` +- Test: `tests/ccbot/test_skill_registry.py` + +- [ ] **Step 1: 테스트 파일 생성 — 스킬 스캔 테스트** + +```python +# tests/ccbot/test_skill_registry.py +"""Tests for skill_registry — plugin skill scanning and command registration.""" + +import json +from pathlib import Path + +import pytest + +from ccbot.skill_registry import SkillInfo, SkillRegistry + + +@pytest.fixture +def plugins_dir(tmp_path: Path) -> Path: + """Create a fake plugins cache with SKILL.md files.""" + # superpowers plugin with two skills + sp_dir = tmp_path / "claude-plugins-official" / "superpowers" / "5.0.7" / "skills" + + brainstorm_dir = sp_dir / "brainstorming" + brainstorm_dir.mkdir(parents=True) + (brainstorm_dir / "SKILL.md").write_text( + "---\n" + "name: brainstorming\n" + 'description: "Design features through collaborative dialogue"\n' + "---\n\n# Brainstorming\n" + ) + + debug_dir = sp_dir / "systematic-debugging" + debug_dir.mkdir(parents=True) + (debug_dir / "SKILL.md").write_text( + "---\n" + "name: systematic-debugging\n" + 'description: "Debug issues systematically"\n' + "---\n\n# Debugging\n" + ) + + # pr-review-toolkit plugin + pr_dir = tmp_path / "claude-plugins-official" / "pr-review-toolkit" / "1.0.0" / "skills" + cr_dir = pr_dir / "code-reviewer" + cr_dir.mkdir(parents=True) + (cr_dir / "SKILL.md").write_text( + "---\n" + "name: code-reviewer\n" + 'description: "Review code for quality and security"\n' + "---\n\n# Code Reviewer\n" + ) + + return tmp_path + + +@pytest.fixture +def state_path(tmp_path: Path) -> Path: + return tmp_path / "skill_state.json" + + +def test_scan_finds_all_skills(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + skills = registry.scan() + assert len(skills) == 3 + names = {s.name for s in skills} + assert names == {"brainstorming", "systematic-debugging", "code-reviewer"} + + +def test_command_name_converts_hyphens(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + commands = {s.command for s in registry.skills} + assert "systematic_debugging" in commands + assert "code_reviewer" in commands + assert "brainstorming" in commands + + +def test_slash_command_preserves_original(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + debug_skill = next(s for s in registry.skills if s.name == "systematic-debugging") + assert debug_skill.slash_command == "/systematic-debugging" + + +def test_scan_skips_non_skill_dirs(plugins_dir: Path, state_path: Path) -> None: + # Add a commands/ directory (deprecated, should be skipped) + cmd_dir = plugins_dir / "claude-plugins-official" / "superpowers" / "5.0.7" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "brainstorm.md").write_text("---\ndescription: deprecated\n---\n") + + registry = SkillRegistry(plugins_dir, state_path) + skills = registry.scan() + # Should still be 3, not 4 + assert len(skills) == 3 + + +def test_scan_handles_missing_dir(tmp_path: Path, state_path: Path) -> None: + registry = SkillRegistry(tmp_path / "nonexistent", state_path) + skills = registry.scan() + assert skills == [] + + +def test_name_collision_adds_prefix(tmp_path: Path, state_path: Path) -> None: + """Two plugins with same skill name get prefixed.""" + # Plugin A + a_dir = tmp_path / "plugin-a" / "pkg" / "1.0" / "skills" / "review" + a_dir.mkdir(parents=True) + (a_dir / "SKILL.md").write_text( + "---\nname: review\ndescription: \"Review A\"\n---\n" + ) + # Plugin B + b_dir = tmp_path / "plugin-b" / "pkg" / "1.0" / "skills" / "review" + b_dir.mkdir(parents=True) + (b_dir / "SKILL.md").write_text( + "---\nname: review\ndescription: \"Review B\"\n---\n" + ) + + registry = SkillRegistry(tmp_path, state_path) + registry.scan() + commands = [s.command for s in registry.skills] + # Both should exist, one with prefix + assert len(commands) == 2 + assert len(set(commands)) == 2 # no duplicates +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ccbot/test_skill_registry.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ccbot.skill_registry'` + +- [ ] **Step 3: SkillRegistry 구현** + +```python +# src/ccbot/skill_registry.py +"""Plugin skill scanner — discovers installed Claude Code skills for Telegram command menu. + +Scans ~/.claude/plugins/cache/ at startup, parses SKILL.md frontmatter to extract +name and description, and provides sorted command lists for bot.set_my_commands(). + +Core responsibilities: + - Scan plugin cache directories for SKILL.md files + - Parse YAML frontmatter (name, description) + - Convert skill names to Telegram-compatible commands (hyphens → underscores) + - Resolve name collisions with plugin prefix + - Track usage per project and favorites in skill_state.json +""" + +import json +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path + +from .utils import atomic_write_json + +logger = logging.getLogger(__name__) + + +@dataclass +class SkillInfo: + """A discovered plugin skill.""" + + name: str # Original skill name (e.g. "systematic-debugging") + command: str # Telegram command (e.g. "systematic_debugging") + description: str # Short description for command menu + plugin: str # Parent plugin name (e.g. "superpowers") + slash_command: str # Command to send to Claude (e.g. "/systematic-debugging") + + +class SkillRegistry: + """Discovers and manages Claude Code plugin skills.""" + + def __init__(self, plugins_dir: Path, state_path: Path) -> None: + self._plugins_dir = plugins_dir + self._state_path = state_path + self.skills: list[SkillInfo] = [] + self._command_to_skill: dict[str, SkillInfo] = {} + self._state: dict = {"favorites": [], "usage": {}} + self._load_state() + + def _load_state(self) -> None: + """Load favorites and usage stats from disk.""" + if self._state_path.is_file(): + try: + self._state = json.loads(self._state_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + logger.warning("Failed to load skill state, using defaults") + + def _save_state(self) -> None: + """Persist favorites and usage stats to disk.""" + atomic_write_json(self._state_path, self._state) + + def scan(self) -> list[SkillInfo]: + """Scan plugins cache and discover all skills. + + Looks for SKILL.md files under each plugin's skills/ directory. + Parses YAML frontmatter for name and description. + Returns the list of discovered skills. + """ + self.skills = [] + self._command_to_skill = {} + + if not self._plugins_dir.is_dir(): + logger.warning("Plugins directory not found: %s", self._plugins_dir) + return [] + + # Collect raw skills first, then resolve collisions + raw_skills: list[tuple[str, str, str, str]] = [] # (name, desc, plugin, marketplace) + + for marketplace_dir in sorted(self._plugins_dir.iterdir()): + if not marketplace_dir.is_dir(): + continue + for plugin_dir in sorted(marketplace_dir.iterdir()): + if not plugin_dir.is_dir(): + continue + plugin_name = plugin_dir.name + # Find version directories (e.g. 5.0.7, 1.0.0) + for version_dir in sorted(plugin_dir.iterdir()): + if not version_dir.is_dir(): + continue + skills_dir = version_dir / "skills" + if not skills_dir.is_dir(): + continue + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + continue + name, desc = self._parse_skill_md(skill_md) + if name: + raw_skills.append((name, desc, plugin_name, marketplace_dir.name)) + + # Detect name collisions and resolve + name_count: dict[str, int] = {} + for name, _, _, _ in raw_skills: + cmd = self._to_command(name) + name_count[cmd] = name_count.get(cmd, 0) + 1 + + seen_commands: dict[str, int] = {} + for name, desc, plugin, marketplace in raw_skills: + cmd = self._to_command(name) + if name_count[cmd] > 1: + prefix = self._to_command(plugin)[:10] + cmd = f"{prefix}_{cmd}" + # Handle remaining duplicates with numeric suffix + if cmd in seen_commands: + seen_commands[cmd] += 1 + cmd = f"{cmd}_{seen_commands[cmd]}" + else: + seen_commands[cmd] = 0 + + skill = SkillInfo( + name=name, + command=cmd, + description=desc[:256], # Telegram limit + plugin=plugin, + slash_command=f"/{name}", + ) + self.skills.append(skill) + self._command_to_skill[cmd] = skill + + logger.info("Scanned %d skills from %s", len(self.skills), self._plugins_dir) + return self.skills + + @staticmethod + def _parse_skill_md(path: Path) -> tuple[str, str]: + """Parse YAML frontmatter from a SKILL.md file. + + Returns (name, description). Returns ("", "") if parsing fails. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return ("", "") + + # Match YAML frontmatter between --- delimiters + match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL) + if not match: + return ("", "") + + frontmatter = match.group(1) + name = "" + desc = "" + + for line in frontmatter.splitlines(): + line = line.strip() + if line.startswith("name:"): + name = line[5:].strip().strip("\"'") + elif line.startswith("description:"): + desc = line[12:].strip().strip("\"'") + + # Truncate description to first sentence for readability + if ". " in desc: + desc = desc[: desc.index(". ") + 1] + + return (name, desc) + + @staticmethod + def _to_command(name: str) -> str: + """Convert a skill name to a Telegram-compatible command name. + + Rules: lowercase, hyphens→underscores, strip non-alphanumeric, max 32 chars. + """ + cmd = name.lower().replace("-", "_") + cmd = re.sub(r"[^a-z0-9_]", "", cmd) + return cmd[:32] + + def is_skill(self, command: str) -> bool: + """Check if a command name maps to a registered skill.""" + return command in self._command_to_skill + + def get_slash_command(self, command: str) -> str: + """Get the original slash command for a Telegram command name. + + E.g. "systematic_debugging" → "/systematic-debugging" + """ + skill = self._command_to_skill.get(command) + return skill.slash_command if skill else f"/{command}" + + def record_usage(self, command: str, project_dir: str | None) -> None: + """Record a skill usage for a project directory.""" + if not project_dir: + return + usage = self._state.setdefault("usage", {}) + project_usage = usage.setdefault(project_dir, {}) + project_usage[command] = project_usage.get(command, 0) + 1 + self._save_state() + + def toggle_favorite(self, command: str) -> bool: + """Toggle a skill's favorite status. Returns new favorite state.""" + favorites = self._state.setdefault("favorites", []) + if command in favorites: + favorites.remove(command) + self._save_state() + return False + else: + favorites.append(command) + self._save_state() + return True + + def is_favorite(self, command: str) -> bool: + """Check if a command is favorited.""" + return command in self._state.get("favorites", []) + + def get_sorted_skills(self, project_dir: str | None = None) -> list[SkillInfo]: + """Return skills sorted by: favorites first, then project usage, then alpha.""" + favorites = set(self._state.get("favorites", [])) + usage: dict[str, int] = {} + if project_dir: + usage = self._state.get("usage", {}).get(project_dir, {}) + + def sort_key(s: SkillInfo) -> tuple[int, int, str]: + is_fav = 0 if s.command in favorites else 1 + freq = -(usage.get(s.command, 0)) + return (is_fav, freq, s.command) + + return sorted(self.skills, key=sort_key) +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ccbot/test_skill_registry.py -v` +Expected: 모든 테스트 PASS + +- [ ] **Step 5: 린트 및 타입체크** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/skill_registry.py tests/ccbot/test_skill_registry.py && uv run ruff format --check src/ccbot/skill_registry.py tests/ccbot/test_skill_registry.py && uv run pyright src/ccbot/skill_registry.py` +Expected: 에러 없음 + +- [ ] **Step 6: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/skill_registry.py tests/ccbot/test_skill_registry.py +git commit -m "feat: add SkillRegistry for plugin skill scanning and management" +``` + +--- + +### Task 2: 즐겨찾기/사용빈도 정렬 테스트 + +**Files:** +- Modify: `tests/ccbot/test_skill_registry.py` + +- [ ] **Step 1: 정렬 및 상태 관리 테스트 추가** + +`tests/ccbot/test_skill_registry.py` 끝에 추가: + +```python +def test_toggle_favorite(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + + assert not registry.is_favorite("brainstorming") + result = registry.toggle_favorite("brainstorming") + assert result is True + assert registry.is_favorite("brainstorming") + + result = registry.toggle_favorite("brainstorming") + assert result is False + assert not registry.is_favorite("brainstorming") + + +def test_favorite_persists_to_disk(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + registry.toggle_favorite("brainstorming") + + # Create new registry instance — should load saved state + registry2 = SkillRegistry(plugins_dir, state_path) + assert registry2.is_favorite("brainstorming") + + +def test_record_usage(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + registry.record_usage("brainstorming", "/home/user/project-a") + registry.record_usage("brainstorming", "/home/user/project-a") + registry.record_usage("code_reviewer", "/home/user/project-a") + + # Verify saved state + state = json.loads(state_path.read_text()) + assert state["usage"]["/home/user/project-a"]["brainstorming"] == 2 + assert state["usage"]["/home/user/project-a"]["code_reviewer"] == 1 + + +def test_sorted_skills_favorites_first(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + registry.toggle_favorite("code_reviewer") + + sorted_skills = registry.get_sorted_skills() + assert sorted_skills[0].command == "code_reviewer" + + +def test_sorted_skills_usage_order(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + + project = "/home/user/my-project" + registry.record_usage("systematic_debugging", project) + registry.record_usage("systematic_debugging", project) + registry.record_usage("systematic_debugging", project) + registry.record_usage("brainstorming", project) + + sorted_skills = registry.get_sorted_skills(project_dir=project) + # systematic_debugging (3 uses) should come before brainstorming (1 use) + names = [s.command for s in sorted_skills] + assert names.index("systematic_debugging") < names.index("brainstorming") + + +def test_record_usage_none_project_is_noop(plugins_dir: Path, state_path: Path) -> None: + registry = SkillRegistry(plugins_dir, state_path) + registry.scan() + registry.record_usage("brainstorming", None) + assert not state_path.exists() or "usage" not in json.loads(state_path.read_text()) or json.loads(state_path.read_text())["usage"] == {} +``` + +- [ ] **Step 2: 테스트 실행** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ccbot/test_skill_registry.py -v` +Expected: 모든 테스트 PASS (이미 구현됨) + +- [ ] **Step 3: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add tests/ccbot/test_skill_registry.py +git commit -m "test: add favorite/usage sorting tests for SkillRegistry" +``` + +--- + +### Task 3: bot.py에 스킬 커맨드 등록 연동 + +**Files:** +- Modify: `src/ccbot/bot.py` + +- [ ] **Step 1: SkillRegistry import 및 초기화 추가** + +`bot.py` 상단 import 블록에 추가 (line 60, `from .config import config` 다음): + +```python +from .skill_registry import SkillRegistry +``` + +모듈 레벨 변수 추가 (line 159, `CC_COMMANDS` dict 아래): + +```python +# Skill registry — populated at startup, used for command menu and forwarding +_skill_registry: SkillRegistry | None = None + + +def _get_skill_registry() -> SkillRegistry: + """Get the skill registry singleton. Initialized in post_init.""" + assert _skill_registry is not None, "SkillRegistry not initialized" + return _skill_registry +``` + +- [ ] **Step 2: post_init에서 스킬 스캔 및 커맨드 등록** + +`post_init` 함수 (line 1820) 수정. `global session_monitor, _status_poll_task` 줄을: + +```python +global session_monitor, _status_poll_task, _skill_registry +``` + +로 변경하고, `await application.bot.set_my_commands(bot_commands)` 줄 (line 1838) 직전에 스킬 등록 로직 삽입: + +```python + # Scan plugin skills and add to command menu + plugins_dir = Path.home() / ".claude" / "plugins" / "cache" + skill_state_path = config.config_dir / "skill_state.json" + _skill_registry = SkillRegistry(plugins_dir, skill_state_path) + _skill_registry.scan() + + for skill in _skill_registry.get_sorted_skills(): + bot_commands.append(BotCommand(skill.command, f"↗ {skill.description}")) +``` + +- [ ] **Step 3: forward_command_handler에서 스킬 usage 기록 및 원본 커맨드 복원** + +`forward_command_handler` (line 487)에서, `cc_slash = cmd_text.split("@")[0]` (line 508) 바로 아래에 추가: + +```python + # If this is a skill command, convert to original slash command and record usage + cmd_name = cc_slash.lstrip("/").split()[0] # e.g. "systematic_debugging" + registry = _get_skill_registry() + if registry.is_skill(cmd_name): + original_slash = registry.get_slash_command(cmd_name) + # Preserve any arguments after the command + args = cc_slash[len(cc_slash.split()[0]):] + cc_slash = original_slash + args + # Record usage for this project + session_info = session_manager.get_session_info(wid) + project_dir = session_info.get("cwd") if session_info else None + registry.record_usage(cmd_name, project_dir) +``` + +주의: `wid`가 resolve된 후에 이 코드가 실행되어야 하므로, `wid = session_manager.resolve_window_for_thread(...)` (line 509)와 window 존재 체크 (line 514-518) 이후로 위치를 조정. + +정확한 삽입 위치는 line 520 (`display = session_manager.get_display_name(wid)`) 직전: + +```python + # If this is a skill command, convert to original slash command and record usage + cmd_name = cc_slash.lstrip("/").split()[0] + registry = _get_skill_registry() + if registry.is_skill(cmd_name): + original_slash = registry.get_slash_command(cmd_name) + args = cc_slash[len(cc_slash.split()[0]):] + cc_slash = original_slash + args + session_info = session_manager.get_session_info(wid) + project_dir = session_info.get("cwd") if session_info else None + registry.record_usage(cmd_name, project_dir) +``` + +- [ ] **Step 4: session_manager에 get_session_info 확인** + +`session.py`에 `get_session_info` 메서드가 있는지 확인. 없으면 `session_map.json`에서 cwd를 가져오는 방법을 사용. `session_manager`에서 cwd를 가져오는 기존 메서드를 찾아 사용하거나, 없으면 간단히 추가. + +- [ ] **Step 5: 린트 및 타입체크** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/bot.py src/ccbot/skill_registry.py && uv run pyright src/ccbot/skill_registry.py` +Expected: 에러 없음 + +- [ ] **Step 6: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/bot.py +git commit -m "feat: register plugin skills in Telegram command menu at startup" +``` + +--- + +### Task 4: /favorite 명령 — 텔레그램에서 즐겨찾기 토글 + +**Files:** +- Modify: `src/ccbot/bot.py` +- Modify: `src/ccbot/handlers/callback_data.py` + +- [ ] **Step 1: callback_data에 즐겨찾기 콜백 상수 추가** + +`src/ccbot/handlers/callback_data.py`에 추가: + +```python +CB_FAV_TOGGLE = "fav:" +CB_FAV_PAGE = "favp:" +``` + +- [ ] **Step 2: /favorite 커맨드 핸들러 작성** + +`bot.py`에 `/favorite` 명령 핸들러 추가 (forward_command_handler 앞, 기존 커맨드 핸들러 섹션): + +```python +async def favorite_command( + update: Update, _context: ContextTypes.DEFAULT_TYPE +) -> None: + """Show skill list with favorite toggle buttons.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return + if not update.message: + return + + registry = _get_skill_registry() + skills = registry.get_sorted_skills() + if not skills: + await safe_reply(update.message, "No skills found.") + return + + keyboard = [] + for skill in skills: + star = "⭐ " if registry.is_favorite(skill.command) else "" + keyboard.append([ + InlineKeyboardButton( + f"{star}{skill.command} — {skill.description[:40]}", + callback_data=f"{CB_FAV_TOGGLE}{skill.command}", + ) + ]) + + await safe_reply( + update.message, + "Tap to toggle favorite:", + reply_markup=InlineKeyboardMarkup(keyboard), + ) +``` + +- [ ] **Step 3: callback_handler에 즐겨찾기 토글 처리 추가** + +`bot.py`의 `callback_handler` 함수에서 기존 콜백 분기에 추가: + +```python + if data.startswith(CB_FAV_TOGGLE): + cmd = data[len(CB_FAV_TOGGLE):] + registry = _get_skill_registry() + is_fav = registry.toggle_favorite(cmd) + star = "⭐ " if is_fav else "" + await query.answer(f"{star}{cmd} {'added to' if is_fav else 'removed from'} favorites") + + # Rebuild the keyboard with updated stars + skills = registry.get_sorted_skills() + keyboard = [] + for skill in skills: + s = "⭐ " if registry.is_favorite(skill.command) else "" + keyboard.append([ + InlineKeyboardButton( + f"{s}{skill.command} — {skill.description[:40]}", + callback_data=f"{CB_FAV_TOGGLE}{skill.command}", + ) + ]) + await query.edit_message_reply_markup( + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + # Re-register commands with new order + 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"), + BotCommand("favorite", "Toggle skill favorites"), + ] + for cmd_name, desc in CC_COMMANDS.items(): + bot_commands.append(BotCommand(cmd_name, desc)) + for skill in registry.get_sorted_skills(): + bot_commands.append(BotCommand(skill.command, f"↗ {skill.description}")) + await query.get_bot().set_my_commands(bot_commands) + return +``` + +- [ ] **Step 4: create_bot에 CommandHandler 등록** + +`create_bot()` 함수에서 `application.add_handler(CommandHandler("usage", usage_command))` 바로 뒤에 추가: + +```python + application.add_handler(CommandHandler("favorite", favorite_command)) +``` + +- [ ] **Step 5: post_init의 bot_commands에 favorite 추가** + +`post_init`의 `bot_commands` 리스트에 추가: + +```python + BotCommand("favorite", "Toggle skill favorites"), +``` + +- [ ] **Step 6: CB_FAV_TOGGLE import 추가** + +`bot.py` 상단의 `callback_data` import에 추가: + +```python + CB_FAV_TOGGLE, +``` + +- [ ] **Step 7: 린트 및 타입체크** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/bot.py src/ccbot/handlers/callback_data.py && uv run pyright src/ccbot/bot.py` +Expected: 에러 없음 + +- [ ] **Step 8: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/bot.py src/ccbot/handlers/callback_data.py +git commit -m "feat: add /favorite command for toggling skill favorites in Telegram" +``` + +--- + +### Task 5: 커맨드 재등록 헬퍼 함수 리팩토링 + +**Files:** +- Modify: `src/ccbot/bot.py` + +- [ ] **Step 1: 커맨드 목록 빌드 로직을 헬퍼로 추출** + +Task 4에서 `post_init`과 `callback_handler` 양쪽에 커맨드 빌드 로직이 중복됨. 헬퍼 함수로 추출: + +```python +def _build_bot_commands() -> list[BotCommand]: + """Build the full list of bot commands: built-in + CC + skills.""" + 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"), + BotCommand("favorite", "Toggle skill favorites"), + ] + for cmd_name, desc in CC_COMMANDS.items(): + commands.append(BotCommand(cmd_name, desc)) + + if _skill_registry: + for skill in _skill_registry.get_sorted_skills(): + commands.append(BotCommand(skill.command, f"↗ {skill.description}")) + + return commands +``` + +- [ ] **Step 2: post_init과 callback_handler에서 헬퍼 사용** + +`post_init`에서: +```python + await application.bot.set_my_commands(_build_bot_commands()) +``` + +`callback_handler`의 즐겨찾기 토글 콜백에서: +```python + await query.get_bot().set_my_commands(_build_bot_commands()) +``` + +- [ ] **Step 3: 린트** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ccbot/bot.py && uv run ruff format --check src/ccbot/bot.py` +Expected: 에러 없음 + +- [ ] **Step 4: 커밋** + +```bash +cd /Users/pakjungeol/Documents/Claude/ccbot-src +git add src/ccbot/bot.py +git commit -m "refactor: extract _build_bot_commands helper to eliminate duplication" +``` + +--- + +### Task 6: 통합 테스트 및 수동 검증 + +**Files:** +- Test: manual verification + +- [ ] **Step 1: 전체 테스트 실행** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run pytest tests/ -v` +Expected: 모든 테스트 PASS + +- [ ] **Step 2: 린트 + 타입체크 전체** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run pyright src/ccbot/` +Expected: 에러 없음 + +- [ ] **Step 3: ccbot 재시작하여 수동 검증** + +Run: `cd /Users/pakjungeol/Documents/Claude/ccbot-src && ./scripts/restart.sh` + +검증 항목: +1. 텔레그램에서 `/` 입력 시 플러그인 스킬 목록이 표시되는지 +2. 스킬 탭 시 Claude에 올바른 슬래시 커맨드가 전달되는지 +3. `/favorite` 명령으로 즐겨찾기 토글이 동작하는지 +4. 즐겨찾기 토글 후 `/` 메뉴에서 순서가 변경되는지 + +- [ ] **Step 4: 최종 커밋 (필요 시)** + +수동 검증 중 발견된 수정사항이 있으면 커밋. From 5247b2fe135c6f0db63e8d1b9376dc5b7884e64e Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 10 Apr 2026 11:24:13 +0900 Subject: [PATCH 25/35] fix: skip session_map update for non-interactive claude -p sessions --- src/ccbot/hook.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index 23a6c58a..8a1a44b1 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -186,6 +186,26 @@ def hook_main() -> None: logger.debug("Ignoring non-SessionStart event: %s", event) return + # Skip non-interactive sessions (claude -p / --print). + # These are one-shot commands (e.g. daily-news-digest.sh) that inherit + # TMUX_PANE from the parent shell but should not overwrite session_map. + try: + ppid = os.getppid() + cmdline = Path(f"/proc/{ppid}/cmdline").read_bytes().decode(errors="ignore") + except (OSError, FileNotFoundError): + # macOS: no /proc, use ps instead + try: + ps_out = subprocess.run( + ["ps", "-o", "args=", "-p", str(os.getppid())], + capture_output=True, text=True, + ).stdout.strip() + cmdline = ps_out + except Exception: + cmdline = "" + if any(flag in cmdline for flag in [" -p ", " --print ", " -p\x00", "\x00-p\x00"]): + logger.debug("Skipping non-interactive session (parent has -p/--print flag)") + 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", "") From c7fd17cb9ecddd9992c41049a5dac9c992251773 Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 10 Apr 2026 11:31:24 +0900 Subject: [PATCH 26/35] feat: align skill commands with CLI format (plugin:skill-dir-name) --- src/ccbot/bot.py | 105 ++++++++++++++--------------- src/ccbot/skill_registry.py | 61 +++++++---------- tests/ccbot/test_skill_registry.py | 29 ++++---- 3 files changed, 94 insertions(+), 101 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 93423c3b..34683ce3 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -162,66 +162,65 @@ _skill_registry: SkillRegistry | None = None -# Korean descriptions for known skills (command_name → description) +# Korean descriptions for known skills (tg_command → description) +# Keys use the new format: plugin_skilldir (e.g. superpowers_brainstorming) _SKILL_DESC_KO: dict[str, str] = { # superpowers - "brainstorming": "브레인스토밍 — 기능 설계 전 아이디어 구체화", - "writing_plans": "구현 계획 작성", - "executing_plans": "구현 계획 실행", - "systematic_debugging": "체계적 디버깅", - "test_driven_development": "TDD — 테스트 주도 개발", - "requesting_code_review": "코드 리뷰 요청", - "receiving_code_review": "코드 리뷰 피드백 적용", - "dispatching_parallel_agents": "병렬 에이전트 분산", - "using_git_worktrees": "Git worktree 격리 작업", - "finishing_a_development_branch": "개발 브랜치 마무리", - "using_superpowers": "Superpowers 스킬 안내", - "verification_before_completion": "완료 전 검증", - "writing_skills": "새 스킬 작성", - "subagent_driven_development": "서브에이전트 기반 개발", + "superpowers_brainstorming": "브레인스토밍 — 기능 설계 전 아이디어 구체화", + "superpowers_writing_plans": "구현 계획 작성", + "superpowers_executing_plans": "구현 계획 실행", + "superpowers_systematic_debugging": "체계적 디버깅", + "superpowers_test_driven_developmen": "TDD — 테스트 주도 개발", + "superpowers_requesting_code_revie": "코드 리뷰 요청", + "superpowers_receiving_code_review": "코드 리뷰 피드백 적용", + "superpowers_dispatching_parallel_": "병렬 에이전트 분산", + "superpowers_using_git_worktrees": "Git worktree 격리 작업", + "superpowers_finishing_a_developme": "개발 브랜치 마무리", + "superpowers_using_superpowers": "Superpowers 스킬 안내", + "superpowers_verification_before_c": "완료 전 검증", + "superpowers_writing_skills": "새 스킬 작성", + "superpowers_subagent_driven_devel": "서브에이전트 기반 개발", # nestjs-hexagonal - "domain": "NestJS 도메인 레이어 (Entity, VO, Event)", - "application": "NestJS 애플리케이션 레이어 (UseCase, CQRS)", - "infrastructure": "NestJS 인프라 레이어 (Repository, Module)", - "presentation": "NestJS 프레젠테이션 레이어 (Controller, DTO)", - "create_subdomain": "NestJS 바운디드 컨텍스트 생성", - "event_listeners": "NestJS 도메인 이벤트 리스너", - "review_subdomain": "NestJS 헥사고날 아키텍처 리뷰", - "using_nestjs_hexagonal": "NestJS 헥사고날 라우터", - "websocket_broadcasting": "WebSocket 브로드캐스팅", - "gsd_installer": "GSD 워크플로 설정", + "nestjs_hexagonal_domain": "NestJS 도메인 레이어", + "nestjs_hexagonal_application": "NestJS 애플리케이션 레이어", + "nestjs_hexagonal_infrastructure": "NestJS 인프라 레이어", + "nestjs_hexagonal_presentation": "NestJS 프레젠테이션 레이어", + "nestjs_hexagonal_create_subdomai": "NestJS 바운디드 컨텍스트 생성", + "nestjs_hexagonal_event_listeners": "NestJS 도메인 이벤트 리스너", + "nestjs_hexagonal_review_subdomai": "NestJS 헥사고날 아키텍처 리뷰", + "nestjs_hexagonal_using_nestjs_hex": "NestJS 헥사고날 라우터", + "nestjs_hexagonal_websocket_broad": "WebSocket 브로드캐스팅", + "nestjs_hexagonal_gsd_installer": "GSD 워크플로 설정", # figma - "figma_use": "Figma 파일 읽기 (필수 전처리)", - "figma_implement_design": "Figma → 코드 구현", - "figma_generate_design": "코드 → Figma 디자인 생성", - "figma_generate_library": "Figma 디자인 시스템 빌드", - "figma_code_connect": "Figma Code Connect 매핑", - "figma_create_new_file": "Figma 새 파일 생성", - "figma_create_design_system": "Figma 디자인 시스템 규칙 생성", + "figma_figma_use": "Figma 파일 읽기 (필수 전처리)", + "figma_figma_implement_design": "Figma → 코드 구현", + "figma_figma_generate_design": "코드 → Figma 디자인 생성", + "figma_figma_generate_library": "Figma 디자인 시스템 빌드", + "figma_figma_code_connect": "Figma Code Connect 매핑", + "figma_figma_create_new_file": "Figma 새 파일 생성", + "figma_figma_create_design_system": "Figma 디자인 시스템 규칙 생성", # frontend-design - "frontend_design": "프론트엔드 UI 디자인 구현", + "frontend_design_frontend_design": "프론트엔드 UI 디자인 구현", # octo - "skill_debug": "Octo 디버깅", - "skill_tdd": "Octo TDD", - "skill_code_review": "Octo 코드 리뷰", - "skill_prd": "Octo PRD 작성", - "skill_audit": "Octo 보안 감사", - "skill_deck": "Octo 슬라이드 덱 생성", - "skill_parallel_agents": "Octo 병렬 에이전트", - "octopus_quick": "Octo 빠른 실행", - "octopus_quick_review": "Octo 빠른 리뷰", - "octopus_architecture": "Octo 아키텍처 설계", - "octopus_security_audit": "Octo 보안 감사", - "flow_discover": "Octo 발견 단계", - "flow_define": "Octo 정의 단계", - "flow_develop": "Octo 개발 단계", - "flow_deliver": "Octo 배포 단계", - "flow_spec": "Octo 스펙 작성", - "skill_debate": "Octo AI 토론", + "octo_skill_debug": "Octo 디버깅", + "octo_skill_tdd": "Octo TDD", + "octo_skill_code_review": "Octo 코드 리뷰", + "octo_skill_prd": "Octo PRD 작성", + "octo_skill_audit": "Octo 보안 감사", + "octo_skill_deck": "Octo 슬라이드 덱 생성", + "octo_skill_parallel_agents": "Octo 병렬 에이전트", + "octo_octopus_quick": "Octo 빠른 실행", + "octo_octopus_quick_review": "Octo 빠른 리뷰", + "octo_octopus_architecture": "Octo 아키텍처 설계", + "octo_octopus_security_audit": "Octo 보안 감사", + "octo_flow_discover": "Octo 발견 단계", + "octo_flow_define": "Octo 정의 단계", + "octo_flow_develop": "Octo 개발 단계", + "octo_flow_deliver": "Octo 배포 단계", + "octo_flow_spec": "Octo 스펙 작성", + "octo_skill_debate": "Octo AI 토론", # pr-review-toolkit - "skill_staged_review": "단계별 코드 리뷰", - "skill_finish_branch": "브랜치 마무리", - "skill_validate": "스킬 검증", + "pr_review_toolkit_staged_review": "단계별 코드 리뷰", } diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index 2b663bf0..ce414079 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -54,13 +54,17 @@ def _save_state(self) -> None: atomic_write_json(self._state_path, self._state) def scan(self) -> list[SkillInfo]: - """Scan plugins cache directory and return discovered skills.""" + """Scan plugins cache directory and return discovered skills. + + Produces CLI-compatible slash commands in the form /plugin:skill-dir-name + (e.g. /superpowers:brainstorming, /octo:km). Telegram commands use + underscores: superpowers_brainstorming, octo_km. + """ if not self._plugins_dir.is_dir(): logger.warning("Plugins directory not found: %s", self._plugins_dir) return [] - # Collect raw skill entries: (plugin_name, skill_name, description) - raw: list[tuple[str, str, str]] = [] + skills: dict[str, SkillInfo] = {} for marketplace_dir in sorted(self._plugins_dir.iterdir()): if not marketplace_dir.is_dir(): continue @@ -81,43 +85,28 @@ def scan(self) -> list[SkillInfo]: skill_md = skill_dir / "SKILL.md" if not skill_md.is_file(): continue - name, description = self._parse_skill_md(skill_md) - if name and description: - raw.append((plugin_name, name, description)) - - # Convert to commands and detect collisions - command_map: dict[str, list[tuple[str, str, str]]] = {} - for plugin_name, skill_name, description in raw: - cmd = self._to_command(skill_name) - command_map.setdefault(cmd, []).append( - (plugin_name, skill_name, description) - ) + _, description = self._parse_skill_md(skill_md) + if not description: + continue - skills: dict[str, SkillInfo] = {} - for cmd, entries in command_map.items(): - if len(entries) == 1: - plugin_name, skill_name, description = entries[0] - info = SkillInfo( - name=skill_name, - command=cmd, - description=description[:256], - plugin=plugin_name, - slash_command=f"/{skill_name}", - ) - skills[cmd] = info - else: - # Name collision — prefix with shortened plugin name - for plugin_name, skill_name, description in entries: - prefix = plugin_name[:10].lower().replace("-", "_") - prefixed_cmd = self._to_command(f"{prefix}_{skill_name}") - info = SkillInfo( - name=skill_name, - command=prefixed_cmd, + # Use directory name as skill identifier (matches CLI behavior) + dir_name = skill_dir.name + # CLI slash command: /plugin:dir-name + slash_cmd = f"/{plugin_name}:{dir_name}" + # Telegram command: plugin_dirname (hyphens→underscores) + tg_cmd = self._to_command(f"{plugin_name}_{dir_name}") + + if tg_cmd in skills: + # Skip duplicates (shouldn't happen with plugin prefix) + continue + + skills[tg_cmd] = SkillInfo( + name=f"{plugin_name}:{dir_name}", + command=tg_cmd, description=description[:256], plugin=plugin_name, - slash_command=f"/{skill_name}", + slash_command=slash_cmd, ) - skills[prefixed_cmd] = info self._skills = skills logger.info("Scanned %d skills from %s", len(skills), self._plugins_dir) diff --git a/tests/ccbot/test_skill_registry.py b/tests/ccbot/test_skill_registry.py index 358cbfbb..0d4d8bd1 100644 --- a/tests/ccbot/test_skill_registry.py +++ b/tests/ccbot/test_skill_registry.py @@ -57,7 +57,11 @@ def test_scan_finds_all_skills(self, tmp_path: Path) -> None: assert len(skills) == 3 names = {s.name for s in skills} - assert names == {"brainstorming", "systematic-debugging", "code-reviewer"} + assert names == { + "superpowers:brainstorming", + "superpowers:systematic-debugging", + "pr-review-toolkit:code-reviewer", + } def test_scan_skips_non_skill_dirs(self, tmp_path: Path) -> None: plugins_dir = tmp_path / "cache" @@ -89,7 +93,7 @@ def test_scan_skips_non_skill_dirs(self, tmp_path: Path) -> None: skills = reg.scan() assert len(skills) == 1 - assert skills[0].name == "brainstorming" + assert skills[0].name == "superpowers:brainstorming" def test_scan_handles_missing_dir(self, tmp_path: Path) -> None: plugins_dir = tmp_path / "nonexistent" @@ -119,11 +123,12 @@ def test_slash_command_preserves_original(self, tmp_path: Path) -> None: reg = SkillRegistry(plugins_dir, tmp_path / "state.json") reg.scan() - assert reg.get_slash_command("systematic_debugging") == "/systematic-debugging" + assert reg.get_slash_command("superpowers_systematic_debugging") == "/superpowers:systematic-debugging" class TestNameCollision: - def test_name_collision_adds_prefix(self, tmp_path: Path) -> None: + def test_no_collision_with_plugin_prefix(self, tmp_path: Path) -> None: + """Different plugins with same skill dir name get unique commands via plugin prefix.""" plugins_dir = tmp_path / "cache" _make_skill_md( plugins_dir, "official", "plugin-a", "1.0.0", "review", "Review A" @@ -234,11 +239,11 @@ def test_sorted_skills_favorites_first(self, tmp_path: Path) -> None: reg = SkillRegistry(plugins_dir, tmp_path / "state.json") reg.scan() - reg.toggle_favorite("zzz_skill") + reg.toggle_favorite("superpowers_zzz_skill") sorted_skills = reg.get_sorted_skills() - assert sorted_skills[0].command == "zzz_skill" - assert sorted_skills[1].command == "aaa_skill" + assert sorted_skills[0].command == "superpowers_zzz_skill" + assert sorted_skills[1].command == "superpowers_aaa_skill" def test_sorted_skills_usage_order(self, tmp_path: Path) -> None: plugins_dir = tmp_path / "cache" @@ -253,10 +258,10 @@ def test_sorted_skills_usage_order(self, tmp_path: Path) -> None: reg.scan() project = "/my/project" - reg.record_usage("zzz_skill", project) - reg.record_usage("zzz_skill", project) - reg.record_usage("aaa_skill", project) + reg.record_usage("superpowers_zzz_skill", project) + reg.record_usage("superpowers_zzz_skill", project) + reg.record_usage("superpowers_aaa_skill", project) sorted_skills = reg.get_sorted_skills(project_dir=project) - assert sorted_skills[0].command == "zzz_skill" - assert sorted_skills[1].command == "aaa_skill" + assert sorted_skills[0].command == "superpowers_zzz_skill" + assert sorted_skills[1].command == "superpowers_aaa_skill" From e9dbf44c37d7a3e7767a533c78d947c31701d63b Mon Sep 17 00:00:00 2001 From: TejNote Date: Fri, 10 Apr 2026 11:38:26 +0900 Subject: [PATCH 27/35] =?UTF-8?q?feat:=20scan=20commands/=20directory=20to?= =?UTF-8?q?o=20=E2=80=94=20now=20includes=20/octo:octo=20and=20all=20CLI?= =?UTF-8?q?=20slash=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ccbot/skill_registry.py | 111 +++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index ce414079..773a0d38 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -56,9 +56,15 @@ def _save_state(self) -> None: def scan(self) -> list[SkillInfo]: """Scan plugins cache directory and return discovered skills. - Produces CLI-compatible slash commands in the form /plugin:skill-dir-name - (e.g. /superpowers:brainstorming, /octo:km). Telegram commands use - underscores: superpowers_brainstorming, octo_km. + Scans both skills/ (SKILL.md) and commands/ (*.md) directories. + CLI slash commands use /plugin:name format (e.g. /octo:octo, /superpowers:brainstorming). + Telegram commands use underscores: octo_octo, superpowers_brainstorming. + + For commands/, the filename determines the command name: + - octo plugin: octo-km.md → /octo:km, octo.md → /octo:octo + - claude-hud: setup.md → /claude-hud:setup + Deprecated commands (description contains "deprecated") are skipped. + Skills take priority over commands with the same resulting tg_cmd. """ if not self._plugins_dir.is_dir(): logger.warning("Plugins directory not found: %s", self._plugins_dir) @@ -72,41 +78,78 @@ def scan(self) -> list[SkillInfo]: if not plugin_dir.is_dir(): continue plugin_name = plugin_dir.name - # Pick the latest version directory only version_dir = self._latest_version_dir(plugin_dir) if not version_dir: continue + + # 1. Scan skills/ directory (SKILL.md files) skills_dir = version_dir / "skills" - if not skills_dir.is_dir(): - continue - for skill_dir in sorted(skills_dir.iterdir()): - if not skill_dir.is_dir(): - continue - skill_md = skill_dir / "SKILL.md" - if not skill_md.is_file(): - continue - _, description = self._parse_skill_md(skill_md) - if not description: - continue - - # Use directory name as skill identifier (matches CLI behavior) - dir_name = skill_dir.name - # CLI slash command: /plugin:dir-name - slash_cmd = f"/{plugin_name}:{dir_name}" - # Telegram command: plugin_dirname (hyphens→underscores) - tg_cmd = self._to_command(f"{plugin_name}_{dir_name}") - - if tg_cmd in skills: - # Skip duplicates (shouldn't happen with plugin prefix) - continue - - skills[tg_cmd] = SkillInfo( - name=f"{plugin_name}:{dir_name}", - command=tg_cmd, - description=description[:256], - plugin=plugin_name, - slash_command=slash_cmd, - ) + if skills_dir.is_dir(): + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + continue + _, description = self._parse_skill_md(skill_md) + if not description: + continue + + dir_name = skill_dir.name + slash_cmd = f"/{plugin_name}:{dir_name}" + tg_cmd = self._to_command(f"{plugin_name}_{dir_name}") + + if tg_cmd in skills: + continue + + skills[tg_cmd] = SkillInfo( + name=f"{plugin_name}:{dir_name}", + command=tg_cmd, + description=description[:256], + plugin=plugin_name, + slash_command=slash_cmd, + ) + + # 2. Scan commands/ directory (*.md files) + commands_dir = version_dir / "commands" + if commands_dir.is_dir(): + for cmd_file in sorted(commands_dir.iterdir()): + if not cmd_file.is_file() or cmd_file.suffix != ".md": + continue + _, description = self._parse_skill_md(cmd_file) + if not description: + continue + # Skip deprecated commands + if "deprecated" in description.lower(): + continue + + # Derive command name from filename + # e.g. octo-km.md → km, octo.md → octo, setup.md → setup + file_stem = cmd_file.stem + # Strip plugin-name prefix if present + # e.g. "octo-km" → "km", "octo" stays "octo" + prefix = plugin_name + "-" + if file_stem.startswith(prefix): + cmd_name = file_stem[len(prefix):] + elif file_stem == plugin_name: + cmd_name = plugin_name + else: + cmd_name = file_stem + + slash_cmd = f"/{plugin_name}:{cmd_name}" + tg_cmd = self._to_command(f"{plugin_name}_{cmd_name}") + + if tg_cmd in skills: + # Skills take priority over commands + continue + + skills[tg_cmd] = SkillInfo( + name=f"{plugin_name}:{cmd_name}", + command=tg_cmd, + description=description[:256], + plugin=plugin_name, + slash_command=slash_cmd, + ) self._skills = skills logger.info("Scanned %d skills from %s", len(skills), self._plugins_dir) From 011e72681e89de111348cb7a6170decd82995161 Mon Sep 17 00:00:00 2001 From: TejNote Date: Mon, 13 Apr 2026 09:45:20 +0900 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20/clear=20=ED=9B=84=20session=5Fmap?= =?UTF-8?q?=20=EB=AF=B8=EA=B0=B1=EC=8B=A0=EC=9C=BC=EB=A1=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A0=84=EB=8B=AC=20=EC=95=88=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /clear는 같은 프로세스 안에서 session_id만 리셋하기 때문에 SessionStart 훅이 재실행되지 않아 session_map.json이 갱신 안 됨. 결과: 새 세션의 JSONL이 모니터링 대상에서 빠져 텔레그램 전달 불가. _auto_detect_session_changes() 추가: - 매 폴링마다 각 window의 project dir에서 더 새로운 JSONL 확인 - 발견 시 session_map.json 자동 업데이트 - 성능 최적화: tracked JSONL이 활발히 크고 있으면 dir scan 스킵 (stat 1회로 판단), stale 시에만 glob 실행 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/session_monitor.py | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index 0a1b3186..c95fe675 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -14,6 +14,7 @@ import asyncio import json import logging +import re from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Awaitable @@ -28,6 +29,8 @@ logger = logging.getLogger(__name__) +_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}$") + @dataclass class SessionInfo: @@ -84,6 +87,8 @@ def __init__( self._last_session_map: dict[str, str] = {} # window_key -> session_id # In-memory mtime cache for quick file change detection (not persisted) self._file_mtimes: dict[str, float] = {} # session_id -> last_seen_mtime + # Cache for auto-detect: skip dir scan when tracked JSONL is actively growing + self._auto_detect_mtimes: dict[str, float] = {} # window_key -> last_seen_mtime def set_message_callback( self, callback: Callable[[NewMessage], Awaitable[None]] @@ -466,6 +471,93 @@ async def _detect_and_cleanup_changes(self) -> dict[str, str]: return current_map + async def _auto_detect_session_changes(self) -> bool: + """Detect session_id changes not caught by hook (e.g., /clear). + + For each window in session_map, check if a newer main JSONL exists + in the project directory. If found, update session_map.json so the + monitor picks up the new session automatically. + """ + if not config.session_map_file.exists(): + return False + + try: + async with aiofiles.open(config.session_map_file, "r") as f: + raw = await f.read() + session_map = json.loads(raw) + except (json.JSONDecodeError, OSError): + return False + + prefix = f"{config.tmux_session_name}:" + changed = False + + for key, info in session_map.items(): + if not key.startswith(prefix): + continue + + cwd = info.get("cwd", "") + current_sid = info.get("session_id", "") + if not cwd or not current_sid: + continue + + # cwd → project dir (same convention as ~/.claude/projects/) + project_dir = self.projects_path / ("-" + cwd.strip("/").replace("/", "-")) + if not project_dir.exists(): + continue + + # Current tracked JSONL mtime + current_jsonl = project_dir / f"{current_sid}.jsonl" + try: + current_mtime = ( + current_jsonl.stat().st_mtime if current_jsonl.exists() else 0 + ) + except OSError: + current_mtime = 0 + + # Skip dir scan if tracked JSONL is still actively growing + last_seen = self._auto_detect_mtimes.get(key, 0) + if current_mtime > last_seen: + # File is growing → no need to scan for replacements + self._auto_detect_mtimes[key] = current_mtime + continue + # mtime unchanged → file is stale, scan for a newer session + + # Find a newer main session JSONL + newest_sid = None + newest_mtime = current_mtime + + for jsonl_file in project_dir.glob("*.jsonl"): + stem = jsonl_file.stem + if stem.startswith("agent-") or not _UUID_RE.match(stem): + continue + try: + file_mtime = jsonl_file.stat().st_mtime + except OSError: + continue + if file_mtime > newest_mtime: + newest_mtime = file_mtime + newest_sid = stem + + if newest_sid and newest_sid != current_sid: + logger.info( + "Auto-detected session change for %s: %s -> %s", + key, + current_sid, + newest_sid, + ) + info["session_id"] = newest_sid + changed = True + + if changed: + try: + async with aiofiles.open(config.session_map_file, "w") as f: + await f.write(json.dumps(session_map, indent=2)) + except OSError as e: + logger.error("Failed to update session_map.json: %s", e) + return False + + return changed + async def _monitor_loop(self) -> None: """Background loop for checking session updates. @@ -486,6 +578,9 @@ async def _monitor_loop(self) -> None: # Load hook-based session map updates await session_manager.load_session_map() + # Auto-detect session changes not caught by hook (/clear, etc.) + await self._auto_detect_session_changes() + # Detect session_map changes and cleanup replaced/removed sessions current_map = await self._detect_and_cleanup_changes() active_session_ids = set(current_map.values()) From 937693ffb6b80fc2af56ed749f276a767ab4e5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=EA=B1=B8?= Date: Mon, 27 Apr 2026 12:17:39 +0900 Subject: [PATCH 29/35] =?UTF-8?q?fix(batcher):=20batch=20summary=20?= =?UTF-8?q?=EA=B0=80=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=81=90=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CCBOT_BATCH_WINDOW > 0 일 때 batcher.flush_and_send / _timer_loop 가 safe_send 로 메시지를 직접 보내면서 message_queue worker 와 race — 결과적으로 텔레그램에 결과 응답이 먼저 도착하고 thinking/tool batch 요약이 뒤따라 도착하던 순서 역전 버그. batcher 도 enqueue_direct_message 로 큐를 통과시켜 다른 content task 와 같은 FIFO 줄에 서도록 수정. 큐 worker 가 user 별 단일 처리이므로 순서 보장. 검증: - ruff/pyright 통과 - 251 tests 통과 - ccbot 재시작 후 message queue worker 정상 시작 확인 Co-authored-by: Claude Opus 4.7 (1M context) --- src/ccbot/message_batcher.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ccbot/message_batcher.py b/src/ccbot/message_batcher.py index ad178b1d..4a602974 100644 --- a/src/ccbot/message_batcher.py +++ b/src/ccbot/message_batcher.py @@ -4,6 +4,11 @@ per (user_id, thread_id) and flushed as a single summary after N seconds, or immediately before a final text response. +Summaries go through the per-user message queue (via enqueue_direct_message) +to preserve FIFO ordering relative to Claude's content responses; bypassing +the queue with a direct safe_send caused batch summaries to race against +content messages and arrive out of order. + Format: ⚙️ 작업 중 (10초간 6건) • Bash × 3 @@ -20,7 +25,7 @@ from telegram import Bot -from .handlers.message_sender import safe_send +from .handlers.message_queue import enqueue_direct_message from .session import session_manager logger = logging.getLogger(__name__) @@ -72,7 +77,11 @@ def add( async def flush_and_send( self, bot: Bot, user_id: int, thread_id: int | None ) -> None: - """Flush buffer and send summary. Called before a final text response.""" + """Flush buffer and enqueue summary. Called before a final text response. + + Routes through the per-user message queue so the summary keeps its + FIFO position relative to Claude's content responses. + """ key = (user_id, thread_id) entries = self._buffers.pop(key, []) elapsed = time.monotonic() - self._start_times.pop(key, time.monotonic()) @@ -80,7 +89,7 @@ async def flush_and_send( return text = _format_batch(entries, elapsed) chat_id = session_manager.resolve_chat_id(user_id, thread_id) - await safe_send(bot, chat_id, text, message_thread_id=thread_id) + await enqueue_direct_message(bot, user_id, chat_id, thread_id, text) async def _timer_loop(self) -> None: """Periodically flush all non-empty buffers.""" @@ -91,15 +100,17 @@ async def _timer_loop(self) -> None: keys = list(self._buffers.keys()) for key in keys: entries = self._buffers.pop(key, []) - elapsed = time.monotonic() - self._start_times.pop(key, time.monotonic()) + elapsed = time.monotonic() - self._start_times.pop( + key, time.monotonic() + ) if not entries: continue user_id, thread_id = key text = _format_batch(entries, elapsed) try: chat_id = session_manager.resolve_chat_id(user_id, thread_id) - await safe_send( - self._bot, chat_id, text, message_thread_id=thread_id + await enqueue_direct_message( + self._bot, user_id, chat_id, thread_id, text ) except Exception as e: logger.error("Batcher flush error for key %s: %s", key, e) From 3b314b15608dd6b3bd83ac2fd81b62c4d16067ce Mon Sep 17 00:00:00 2001 From: Tej Date: Wed, 6 May 2026 16:23:33 +0900 Subject: [PATCH 30/35] fix: persist status msg IDs + clean orphans on restart + ccbot send subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: ccbot 재시작마다 이전 상태 메시지(Metamorphosing…, Baked for Ns 등)가 orphan으로 쌓여 Telegram 토픽에 메시지가 누적됨. Fix: - session.py: status_msg_ids를 state.json에 영속화 - bot.py post_init: 시작 시 orphaned 상태 메시지 일괄 삭제 - message_queue.py: send/delete/convert 시 state.json 동기화 (content 변환 시 ID 미삭제 → content 메시지 삭제되는 버그도 수정) - main.py: ccbot send 서브커맨드 디스패치 추가 Also: ~/.ccbot/.env에 CCBOT_SHOW_TOOL_CALLS=false 추가 권고 (tool_use/result 배치 제외 → 작업 중 요약 빈도 감소) Co-Authored-By: Claude Sonnet 4.6 --- src/ccbot/bot.py | 9 +++++++++ src/ccbot/handlers/message_queue.py | 15 ++++++++------- src/ccbot/main.py | 6 ++++++ src/ccbot/session.py | 30 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 34683ce3..22d78d86 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -2034,6 +2034,15 @@ async def post_init(application: Application) -> None: bot_commands = _build_bot_commands() await application.bot.set_my_commands(bot_commands) + # Delete status messages left over from the previous run (orphaned on restart) + orphaned = session_manager.pop_all_status_msg_ids() + for msg_id, chat_id in orphaned: + try: + await application.bot.delete_message(chat_id=chat_id, message_id=msg_id) + logger.debug("Deleted orphaned status message %d", msg_id) + except Exception: + pass + # Re-resolve stale window IDs from persisted state against live tmux windows await session_manager.resolve_stale_ids() diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index 4a7c83fd..d10967e2 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -381,8 +381,6 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No link_preview_options=NO_LINK_PREVIEW, ) await _send_task_images(bot, chat_id, task) - await asyncio.sleep(0.15) # Wait for Claude TUI to update status - await _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: raise @@ -397,8 +395,6 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No link_preview_options=NO_LINK_PREVIEW, ) await _send_task_images(bot, chat_id, task) - await asyncio.sleep(0.15) # Wait for Claude TUI to update status - await _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: raise @@ -443,9 +439,8 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No # 4. Send images if present (from tool_result with base64 image blocks) await _send_task_images(bot, chat_id, task) - # 5. After content, check and send status - await asyncio.sleep(0.15) # Wait for Claude TUI to update status after response - await _check_and_send_status(bot, user_id, wid, task.thread_id) + # Status display is delegated to status_polling (1s interval) so the answer + # always remains the last visible message until polling detects working state. async def _convert_status_to_content( @@ -466,6 +461,8 @@ async def _convert_status_to_content( msg_id, stored_wid, _ = info chat_id = session_manager.resolve_chat_id(user_id, thread_id_or_0 or None) + # Clear persisted ID regardless of outcome — message is no longer a status message + session_manager.clear_status_msg_id(user_id, thread_id_or_0) if stored_wid != window_id: # Different window, just delete the old status try: @@ -610,6 +607,9 @@ async def _do_send_status_message( ) if sent: _status_msg_info[skey] = (sent.message_id, window_id, text) + session_manager.set_status_msg_id( + user_id, thread_id_or_0, sent.message_id, chat_id + ) async def _do_clear_status_message( @@ -627,6 +627,7 @@ async def _do_clear_status_message( await bot.delete_message(chat_id=chat_id, message_id=msg_id) except Exception as e: logger.debug(f"Failed to delete status message {msg_id}: {e}") + session_manager.clear_status_msg_id(user_id, thread_id_or_0) async def _check_and_send_status( diff --git a/src/ccbot/main.py b/src/ccbot/main.py index dabd3fd7..ef63a298 100644 --- a/src/ccbot/main.py +++ b/src/ccbot/main.py @@ -18,6 +18,12 @@ def main() -> None: hook_main() return + if len(sys.argv) > 1 and sys.argv[1] == "send": + from .send import send_main + + send_main() + return + logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.WARNING, diff --git a/src/ccbot/session.py b/src/ccbot/session.py index d4c182f0..a6e3c12b 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -111,6 +111,9 @@ class SessionManager: # History: originally added in 5afc111, erroneously removed in 26cb81f, # restored in PR #23. group_chat_ids: dict[str, int] = field(default_factory=dict) + # Persisted status message IDs for cleanup on restart. + # "user_id:thread_id" -> [msg_id, chat_id] + status_msg_ids: dict[str, list[int]] = field(default_factory=dict) def __post_init__(self) -> None: self._load_state() @@ -127,6 +130,7 @@ def _save_state(self) -> None: }, "window_display_names": self.window_display_names, "group_chat_ids": self.group_chat_ids, + "status_msg_ids": self.status_msg_ids, } atomic_write_json(config.state_file, state) logger.debug("State saved to %s", config.state_file) @@ -160,6 +164,11 @@ def _load_state(self) -> None: self.group_chat_ids = { k: int(v) for k, v in state.get("group_chat_ids", {}).items() } + self.status_msg_ids = { + k: v + for k, v in state.get("status_msg_ids", {}).items() + if isinstance(v, list) and len(v) == 2 + } # Detect old format: keys that don't look like window IDs needs_migration = False @@ -192,6 +201,27 @@ def _load_state(self) -> None: self.group_chat_ids = {} pass + def set_status_msg_id( + self, user_id: int, thread_id: int, msg_id: int, chat_id: int + ) -> None: + key = f"{user_id}:{thread_id}" + self.status_msg_ids[key] = [msg_id, chat_id] + self._save_state() + + def clear_status_msg_id(self, user_id: int, thread_id: int) -> None: + key = f"{user_id}:{thread_id}" + if key in self.status_msg_ids: + del self.status_msg_ids[key] + self._save_state() + + def pop_all_status_msg_ids(self) -> list[tuple[int, int]]: + """Return and clear all persisted (msg_id, chat_id) pairs.""" + items = [(v[0], v[1]) for v in self.status_msg_ids.values()] + self.status_msg_ids.clear() + if items: + self._save_state() + return items + async def resolve_stale_ids(self) -> None: """Re-resolve persisted window IDs against live tmux windows. From 2c4d03aeab4587aa58aef5f7ca980ebc77064a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=EA=B1=B8?= Date: Fri, 8 May 2026 17:03:40 +0900 Subject: [PATCH 31/35] Codex topic routing absorbs claude branch validation assets (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Codex topic routing now matches Claude-style Telegram flow Codex windows keep topic bindings by provider, send input through tmux paste-buffer, and surface Codex working state through a provider-specific status parser while final responses are pushed by the runtime ccbot bridge. Constraint: Preserve the existing 1 Topic = 1 Window = 1 Session contract and window_id-based routing. Rejected: Keep sending Codex pane snapshots from ccbot core | It produced noisy Telegram snapshots instead of Claude-style final responses. Confidence: high Scope-risk: moderate Directive: Runtime validation still depends on the external OMX ccbot-bridge.mjs hook for Codex turn-complete pushes. Tested: git diff --cached --check; uv run --extra dev ruff check src/ tests/; uv run --extra dev ruff format --check src/ tests/; uv run --extra dev pyright src/ccbot/; uv run --extra dev pytest (270 passed) Not-tested: MR creation and remote CI Co-authored-by: OmX * Codex topic routing absorbs claude branch validation assets The Codex base branch (8773850) ships the architectural decisions — auto provider detection, Literal type hint, stale cleanup protection, bot.py / message_queue.py / hook.py integration — but lacks the validation/documentation assets developed on the claude branch (ccbot-codex-connect-by-cluade). This commit imports those assets without altering Codex base architecture. Constraint: Preserve every Codex base behavior (provider detection, session_map cleanup guard, paste-buffer composer route, polling parser); only add backward-compat, regression coverage, and design records. Rejected: Always serialize 'provider' in WindowState.to_dict | every existing state.json row would gain "provider": "claude", silently breaking grep/jq tooling and inflating storage for users on legacy state files. Rejected: Trust the first window_display_names fallback match | stale @old codex window_ids can remain after ccbot kickstart and would route ccbot send --window codex to a dead window_id — silent Telegram fail. Confidence: high Scope-risk: narrow (only WindowState serialization + send fallback guard mutate runtime; rest is docs and tests). Directive: Keep window_display_names fallback constrained to window_ids present in thread_bindings; omit default provider from to_dict; preserve claude branch's M1/M2 plan and the codex thinking pane trace as regression fixture. Imported assets: - plans/2026-05-07-codex-omx-ccbot-연동.md (M1 양방향 폐루프 design) - plans/2026-05-08-codex-thinking-status-알림-design.md (M2 spec) - plans/2026-05-08-codex-thinking-status-구현.md (M2 TDD plan) - tests/ccbot/fixtures/codex_thinking_trace.txt 3,237-line live capture-pane trace anchoring CODEX_THINKING_RE / CODEX_TOOL_RE regression coverage. Code changes: - src/ccbot/session.py — WindowState.to_dict omits provider when default ('claude'); from_dict already restores 'claude' on missing key. - src/ccbot/send.py — _resolve_routing fallback now intersects window_display_names with thread_bindings.values() so stale wids are not picked. Test deltas (+8 net): - tests/ccbot/test_session.py: +2 cases test_to_dict_omits_default_provider_for_backward_compat test_from_dict_legacy_state_defaults_to_claude - tests/ccbot/test_send.py: rebuilt as TestResolveRouting class state_file_missing_returns_none resolve_routing_by_window_name (codex base preserved) window_states_match_by_session_id fallback_to_display_names_when_window_states_empty fallback_skips_stale_window_id_not_in_thread_bindings (new guard) fallback_does_not_apply_for_session_id_only no_match_anywhere_returns_none no_thread_binding_returns_none Tested: uv run --extra dev pytest tests/ -q (278 passed); uv run --extra dev ruff check src/ tests/ (All checks passed); uv run --extra dev ruff format --check src/ tests/ (53 files formatted); uv run --extra dev pyright src/ccbot/ (0 errors, 0 warnings). Not-tested: MR creation and remote CI. Co-authored-by: claude (ccbot-codex-connect-by-cluade) Co-authored-by: codex (codex-connext-by-code, codex-connext-by-code-v2) --------- Co-authored-by: OmX --- plans/2026-05-07-ccbot-codex-provider.md | 77 + ...dex-omx-ccbot-\354\227\260\353\217\231.md" | 506 +++ ...inking-status-\352\265\254\355\230\204.md" | 567 +++ ...status-\354\225\214\353\246\274-design.md" | 177 + pyproject.toml | 7 + src/ccbot/bot.py | 5 +- src/ccbot/handlers/message_queue.py | 17 +- src/ccbot/handlers/status_polling.py | 13 +- src/ccbot/hook.py | 3 +- src/ccbot/send.py | 160 + src/ccbot/session.py | 87 +- src/ccbot/skill_registry.py | 2 +- src/ccbot/terminal_parser.py | 106 +- src/ccbot/tmux_manager.py | 80 +- tests/ccbot/fixtures/codex_thinking_trace.txt | 3237 +++++++++++++++++ tests/ccbot/test_bot_codex.py | 87 + tests/ccbot/test_send.py | 120 + tests/ccbot/test_session.py | 87 + tests/ccbot/test_skill_registry.py | 5 +- tests/ccbot/test_status_polling_codex.py | 157 + tests/ccbot/test_terminal_parser.py | 112 + 21 files changed, 5586 insertions(+), 26 deletions(-) create mode 100644 plans/2026-05-07-ccbot-codex-provider.md create mode 100644 "plans/2026-05-07-codex-omx-ccbot-\354\227\260\353\217\231.md" create mode 100644 "plans/2026-05-08-codex-thinking-status-\352\265\254\355\230\204.md" create mode 100644 "plans/2026-05-08-codex-thinking-status-\354\225\214\353\246\274-design.md" create mode 100644 src/ccbot/send.py create mode 100644 tests/ccbot/fixtures/codex_thinking_trace.txt create mode 100644 tests/ccbot/test_bot_codex.py create mode 100644 tests/ccbot/test_send.py create mode 100644 tests/ccbot/test_status_polling_codex.py diff --git a/plans/2026-05-07-ccbot-codex-provider.md b/plans/2026-05-07-ccbot-codex-provider.md new file mode 100644 index 00000000..09affcc4 --- /dev/null +++ b/plans/2026-05-07-ccbot-codex-provider.md @@ -0,0 +1,77 @@ +# ccbot Codex Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Telegram forum topic에서 기존 Claude window뿐 아니라 ccbot tmux 안의 Codex/OMX direct window로 메시지를 보내고, Codex 상태는 polling으로 표시하며 최종 응답은 외부 Codex/OMX turn-complete bridge가 `ccbot send`로 Telegram topic에 push한다. + +**Architecture:** 큰 provider ABC 리팩터는 보류하고 `WindowState.provider`만 추가한다. `provider="codex"` window는 Claude JSONL/session hook에 의존하지 않고 `tmux paste-buffer`로 입력을 전송한다. 작업중 표시는 codex TUI status parser로 처리하고, 최종 응답 push는 Codex/OMX hook bridge가 `ccbot send --window codex`를 호출한다. OMX는 ccbot 단일 tmux와 충돌하지 않도록 운영에서 `OMX_LAUNCH_POLICY=direct omx` 또는 `omx --direct`로 실행한다. + +**Tech Stack:** Python 3.12, python-telegram-bot, libtmux, pytest, ruff, pyright. + +--- + +## Files + +- Modify: `src/ccbot/session.py` + - `WindowState.provider` 추가 + - window name 기반 `claude|codex` provider 감지 + - Codex window가 Claude `session_map.json` cleanup에서 삭제되지 않도록 보호 +- Modify: `src/ccbot/terminal_parser.py` + - ANSI/control sequence strip helper 추가 + - Codex permission prompt/status line parser 추가 +- Modify: `src/ccbot/bot.py` + - `provider=codex` window는 snapshot 전송 없이 입력/interactive UI만 처리 +- Modify: `src/ccbot/handlers/status_polling.py` + - `provider=codex` window는 Codex status parser로 작업중 메시지 갱신 +- Modify: `src/ccbot/tmux_manager.py` + - Codex composer 입력 안정화를 위해 paste-buffer 전송 지원 +- Add: `src/ccbot/send.py` + - 현재 `main.py`가 이미 import하는 `ccbot send` subcommand 구현 누락 보완 + - `--window ` 기반 topic routing 유지 +- Add/Modify tests: + - `tests/ccbot/test_session.py` + - `tests/ccbot/test_terminal_parser.py` + - `tests/ccbot/test_bot_codex.py` + - `tests/ccbot/test_send.py` + +## Task 1: Provider state model + +- [x] RED: `WindowState`가 `provider="codex"`를 저장/복원하고, `bind_thread(..., window_name="codex")`가 codex provider를 감지하는 테스트 추가. +- [x] RED: `load_session_map()`이 codex window state를 session_map 미등재 stale로 삭제하지 않는 테스트 추가. +- [x] GREEN: `WindowState.provider`, `detect_window_provider`, `get_window_provider`, `set_window_provider` 구현. +- [x] VERIFY: `uv run pytest tests/ccbot/test_session.py -q` 통과. + +## Task 2: Codex terminal parser + +- [x] RED: Codex permission prompt/status line parser 테스트 추가. +- [x] GREEN: `strip_ansi_control_sequences`, `parse_codex_status_line` 구현. +- [x] VERIFY: `uv run pytest tests/ccbot/test_terminal_parser.py -q` 통과. + +## Task 3: Codex send/status loop + +- [x] RED: `text_handler`가 codex provider window에 메시지만 전송하고 snapshot queue를 만들지 않는 테스트 추가. +- [x] GREEN: codex provider 입력 분기, status polling parser 분기, paste-buffer 전송 구현. +- [x] VERIFY: `uv run pytest tests/ccbot/test_bot_codex.py tests/ccbot/test_status_polling_codex.py -q` 통과. + +## Task 4: ccbot send subcommand 보완 + +- [x] RED: `ccbot send --window codex` routing이 `state.json`의 `window_states` + `thread_bindings` + `group_chat_ids`로 chat/thread를 찾는 테스트 추가. +- [x] GREEN: `src/ccbot/send.py` 구현. +- [x] VERIFY: `uv run pytest tests/ccbot/test_send.py -q` 통과. + +## Task 5: 전체 검증 + +- [x] `uv run ruff format src/ tests/ --check` +- [x] `uv run ruff check src/ tests/` +- [x] `uv run pyright src/ccbot/` +- [x] `uv run pytest tests/ccbot/test_session.py tests/ccbot/test_terminal_parser.py tests/ccbot/test_bot_codex.py tests/ccbot/test_send.py -q` + +## Operating note + +ccbot 안 Codex/OMX window는 detached tmux를 만들지 않게 아래 중 하나로 시작한다. + +```bash +OMX_LAUNCH_POLICY=direct omx +# or +omx --direct +``` diff --git "a/plans/2026-05-07-codex-omx-ccbot-\354\227\260\353\217\231.md" "b/plans/2026-05-07-codex-omx-ccbot-\354\227\260\353\217\231.md" new file mode 100644 index 00000000..c03fa3cb --- /dev/null +++ "b/plans/2026-05-07-codex-omx-ccbot-\354\227\260\353\217\231.md" @@ -0,0 +1,506 @@ +# ccbot — Codex/OMX provider 연동 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** ccbot이 Telegram 토픽 ↔ tmux 창 양방향 라우팅을 codex/omx 세션에도 동일하게 제공한다. claude `--resume` 창과 동일한 사용감 (토픽에서 메시지 던지면 codex로 들어가고, codex 응답이 토픽으로 돌아오는 폐루프). + +**Architecture (B-lite):** +- ccbot 본체는 **WindowState에 `provider: Literal["claude", "codex"]` 필드 1개**만 추가. transcript parser ABC, SessionProvider 추상화 등 큰 리팩터는 하지 않는다 (YAGNI). +- 입력 라우팅(텔레그램 → tmux)은 기존 `tmux_manager.send_keys`가 provider 무관이라 **신규 코드 0**. +- 응답 라우팅(codex → 텔레그램)은 ccbot 본체에 폴링 추가하지 않고, **omx의 native hook(Stop)** 에서 capture-pane 후 `ccbot send --window ` CLI를 호출하는 외부 스크립트로 처리. 본체 결합도 0. +- 운영 정책: ccbot tmux 안의 codex 창에서는 항상 `OMX_LAUNCH_POLICY=direct omx`로 부팅. `detached-tmux`(default)는 ccbot 단일 tmux 모델과 충돌하므로 금지. + +**Tech Stack:** Python 3.11+ (ccbot), python-telegram-bot, tmux, pytest, dataclasses; bash (ccbot-start-real.sh), Node.js (omx hook plugin .mjs). + +**Scope:** M1만 다룬다. M2(codex rollout JSONL 파서, terminal_parser codex 호환) 는 1주 사용 후 별도 plan으로 결정 — 본 plan 끝의 "Backlog" 참조. + +**Out-of-repo files (이 레포 밖에서 변경):** +- `~/.local/scripts/ccbot-start-real.sh` — ccbot 부팅 스크립트. plan 안에서 변경 단계를 명시. +- `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs` — omx hook plugin (신규). plan 안에서 작성 단계 명시. 별도 commit/리포 관리 대상은 아님. + +--- + +## File Structure + +| 파일 | 책임 | 동작 | +|---|---|---| +| `src/ccbot/session.py` | `WindowState` dataclass에 `provider` 필드 | 수정 | +| `tests/ccbot/test_session.py` | `WindowState` provider 직렬화/하위호환 검증 | 수정 | +| `~/.local/scripts/ccbot-start-real.sh` | tmux 6창 → 7창(`codex` 추가) + send-keys 명령 분기 | 수정 (외부) | +| `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs` | omx Stop hook → capture-pane → `ccbot send --window` | 신규 (외부) | +| `plans/2026-05-07-codex-omx-ccbot-연동.md` | 본 plan | 신규 | + +분리 근거: ccbot 본체는 데이터 모델 1필드 추가만. 운영 스크립트와 hook bridge는 본체와 책임이 다르고 본체 release cycle과 다른 속도로 변하므로 외부 파일로 분리. + +--- + +## Task 1: WindowState에 `provider` 필드 추가 (TDD) + +**Files:** +- Modify: `src/ccbot/session.py:44-73` +- Test: `tests/ccbot/test_session.py` (기존 파일 또는 신규 — 1단계에서 확인) + +- [ ] **Step 1: 기존 테스트 위치 확인** + +```bash +ls /Users/pakjungeol/Documents/Personal/ccbot-src/tests/ccbot/test_session.py 2>&1 +grep -n "WindowState" /Users/pakjungeol/Documents/Personal/ccbot-src/tests/ccbot/*.py 2>&1 | head -10 +``` + +기존 `test_session.py`가 있고 `WindowState` 테스트가 있으면 거기에 case 추가. 없으면 새 파일 `tests/ccbot/test_window_state_provider.py` 생성. + +- [ ] **Step 2: failing test 작성** + +다음 4개 case를 추가한다 (하위호환이 핵심): + +```python +# tests/ccbot/test_window_state_provider.py (신규 파일이면) +# 또는 tests/ccbot/test_session.py에 추가 + +from ccbot.session import WindowState + + +def test_window_state_default_provider_is_claude(): + """Default provider는 'claude'여서 기존 동작 보존.""" + ws = WindowState(session_id="abc", cwd="/x", window_name="claude") + assert ws.provider == "claude" + + +def test_window_state_can_set_codex_provider(): + """provider='codex' 명시 가능.""" + ws = WindowState(provider="codex", cwd="/x", window_name="codex") + assert ws.provider == "codex" + + +def test_window_state_to_dict_includes_provider_when_codex(): + """직렬화 시 codex provider는 포함, claude(기본)는 생략 — state.json 안 부풀리기.""" + codex_ws = WindowState(provider="codex", window_name="codex", cwd="/x") + assert codex_ws.to_dict()["provider"] == "codex" + + claude_ws = WindowState(window_name="claude", cwd="/x") + assert "provider" not in claude_ws.to_dict() + + +def test_window_state_from_dict_legacy_state_defaults_to_claude(): + """기존 state.json (provider 키 없음) 로드 시 claude 기본값 — 하위호환.""" + legacy = {"session_id": "abc", "cwd": "/x", "window_name": "claude"} + ws = WindowState.from_dict(legacy) + assert ws.provider == "claude" + + +def test_window_state_from_dict_with_provider_codex(): + new = {"session_id": "", "cwd": "/x", "window_name": "codex", "provider": "codex"} + ws = WindowState.from_dict(new) + assert ws.provider == "codex" +``` + +- [ ] **Step 3: 테스트 실행 — fail 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ccbot/test_window_state_provider.py -v 2>&1 | tail -20 +``` + +기대: 5개 모두 FAIL. 메시지 예: `AttributeError: 'WindowState' object has no attribute 'provider'`. + +- [ ] **Step 4: `WindowState`에 provider 필드 + 직렬화 추가** + +`src/ccbot/session.py` line 44-73 변경: + +```python +@dataclass +class WindowState: + """Persistent state for a tmux window. + + Attributes: + session_id: Associated Claude session ID (empty if not yet detected) + cwd: Working directory for direct file path construction + window_name: Display name of the window + provider: "claude" (default) or "codex". Determines which session + model the window holds. codex windows do not have a UUID + session_id and are identified by window_name only. + """ + + session_id: str = "" + cwd: str = "" + window_name: str = "" + provider: str = "claude" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "session_id": self.session_id, + "cwd": self.cwd, + } + if self.window_name: + d["window_name"] = self.window_name + if self.provider != "claude": + d["provider"] = self.provider + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WindowState": + return cls( + session_id=data.get("session_id", ""), + cwd=data.get("cwd", ""), + window_name=data.get("window_name", ""), + provider=data.get("provider", "claude"), + ) +``` + +- [ ] **Step 5: 테스트 실행 — pass 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ccbot/test_window_state_provider.py -v 2>&1 | tail -20 +``` + +기대: 5개 모두 PASS. + +- [ ] **Step 6: 전체 테스트로 회귀 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ -x 2>&1 | tail -20 +``` + +기대: 기존 테스트 모두 PASS. WindowState를 사용하는 모든 호출지에서 provider 누락이 default("claude")로 채워져 무동작. + +- [ ] **Step 7: commit** + +```bash +cd ~/Documents/Personal/ccbot-src +git add src/ccbot/session.py tests/ccbot/test_window_state_provider.py +git commit -m "feat(session): add provider field to WindowState + +claude(default)/codex 두 provider 구분을 위한 단일 필드 추가. +- 기본값 'claude'로 하위호환 (기존 state.json 그대로 로드) +- 직렬화는 'codex'일 때만 포함 (state.json 부풀리지 않음) +- session_id는 codex window에서 비어있을 수 있음 (UUID 없음) + +ccbot이 codex/omx 세션도 라우팅하는 첫 단추." +``` + +--- + +## Task 2: 운영 스크립트에 `codex` window 추가 (외부 파일) + +**Files:** +- Modify: `~/.local/scripts/ccbot-start-real.sh:78-84` (WINDOWS 배열), `:104-118` (send-keys 분기) + +이 파일은 ccbot-src 레포 밖이지만 본 plan task로 포함. 변경 후 launchd kickstart로 검증. + +- [ ] **Step 1: 백업** + +```bash +cp ~/.local/scripts/ccbot-start-real.sh ~/.local/scripts/ccbot-start-real.sh.bak.$(date +%Y%m%d) +``` + +- [ ] **Step 2: WINDOWS 배열에 codex 추가** + +`~/.local/scripts/ccbot-start-real.sh` line 78-84: + +```bash +WINDOWS=( + "main::$HOME" + "ceo::$HOME/Documents/Insudeal/CeoReport" + "metlife::$HOME/Documents/Insudeal/Metlife" + "scraping::$HOME/Documents/Insudeal/Scraping" + "smoking::$HOME/Documents/Personal/smoking-place" + "claude::$HOME/Documents/Claude" + "codex::$HOME/Documents/Claude" +) +``` + +- [ ] **Step 3: send-keys 분기 — codex window는 omx --direct로 부팅** + +기존 line 104-118 (`# 각 창에서 claude --resume 자동 시작` 블록): + +```bash +echo "$(date): claude --resume 시작" +for entry in "${WINDOWS[@]}"; do + name="${entry%%::*}" + [ "$name" = "main" ] && continue + "$TMUX_BIN" send-keys -t "ccbot:$name" 'claude --resume' Enter +done +``` + +다음으로 변경 (codex window는 다른 명령 송신): + +```bash +echo "$(date): per-window startup commands 시작" +for entry in "${WINDOWS[@]}"; do + name="${entry%%::*}" + case "$name" in + main) + # 일반 셸 유지 — 명령 송신 안 함 + ;; + codex) + # OMX_LAUNCH_POLICY=direct: ccbot 단일 tmux session 안에서 + # omx가 자체 tmux/HUD를 만들지 않고 현재 창에 codex를 직접 부팅. + # detached-tmux(default)는 ccbot 모델과 충돌하므로 금지. + "$TMUX_BIN" send-keys -t "ccbot:$name" \ + 'OMX_LAUNCH_POLICY=direct omx' Enter + ;; + *) + "$TMUX_BIN" send-keys -t "ccbot:$name" 'claude --resume' Enter + ;; + esac +done +``` + +- [ ] **Step 4: bash -n 문법 검증** + +```bash +bash -n ~/.local/scripts/ccbot-start-real.sh && echo "syntax OK" +``` + +기대: `syntax OK`. + +- [ ] **Step 5: 기존 ccbot 종료 후 launchd 재시작 — 7창으로 재기동** + +```bash +launchctl kickstart -k gui/$UID/com.pakjungeol.ccbot-start +sleep 5 +tmux list-windows -t ccbot +``` + +기대: 출력에 `codex` window가 포함된 7개 창. 부팅 명령이 정상 송신됐는지 확인. + +```bash +tmux capture-pane -t ccbot:codex -p | tail -10 +``` + +기대: codex/omx 부팅 메시지(`OpenAI Codex v...`). + +- [ ] **Step 6: 안정 운영 확인 (5분)** + +`~/Documents/Claude/logs/ccbot-autostart.log` 마지막 100줄 확인. supervisor가 5분 안에 비정상 종료 카운터를 올리지 않으면 OK. + +```bash +sleep 300 +tail -30 ~/Documents/Claude/logs/ccbot-autostart.log +cat ~/.ccbot/.fail-count 2>/dev/null || echo "(no count)" +``` + +기대: 카운터 0 또는 reset 로그. + +- [ ] **Step 7: 외부 스크립트 변경은 dotfiles 레포에 별도 커밋 (해당 시) — 본 plan에선 ccbot-src commit 없음.** + +`~/.local/scripts/`가 다른 dotfiles 레포에 있으면 거기서 커밋. 없으면 변경된 스크립트 위치만 plan에 기록 (이 단계는 정보 단계, 실행 명령 없음). + +--- + +## Task 3: omx hook plugin — Stop hook → capture-pane → ccbot send + +**Files:** +- Create: `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs` + +omx의 hook plugin 시스템(`omx hooks init/status/validate`)을 이용. plugin은 자동 로드되며 `OMX_HOOK_PLUGINS=0`로 비활성 가능. + +- [ ] **Step 1: 디렉토리 확인** + +```bash +ls -la ~/Documents/Claude/.omx/hooks/ 2>&1 +omx hooks status 2>&1 | head -20 +``` + +기대: 디렉토리 존재 및 `Discovered plugins: 0`. + +- [ ] **Step 2: plugin 파일 작성** + +> SDK 형태 (실측 확인): `omx hooks init` scaffold + `dist/hooks/extensibility/types.d.ts:HookEventName` 기준. +> - **export**: `export async function onHookEvent(event, sdk)` (default export 아님) +> - **이벤트 분기**: `event.event === 'turn-complete'` 등. 사용 가능 이벤트: +> `'session-start' | 'stop' | 'session-end' | 'session-idle' | 'turn-complete' | 'blocked' | 'finished' | 'failed' | 'pre-tool-use' | 'post-tool-use' ...` +> - **SDK**: `sdk.log.info(msg, payload?)`, `sdk.state.read(key)/write(key, value)` 가용. +> - **TMUX_PANE**: plugin runner는 자식 프로세스라 부모 omx의 환경변수가 그대로 전달됨 → `process.env.TMUX_PANE` 사용 가능. + +```javascript +// 본문 정합 reference: ~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs +// (전체 코드는 위 hook 파일 직접 참조 — plan 길이 부담 줄이기 위해 핵심 골격만 발췌) +// +// 기본 골격: +// import { execFileSync } from "node:child_process"; +// import { createHash } from "node:crypto"; +// +// const PROMPT_RE = /^\s*›\s/; +// const RESPONSE_RE = /^\s*•\s/; +// const STATUS_BAR_RE = /^\s*gpt-[\d.]+(?:\s+\w+)?\s+·/; +// const SEPARATOR_RE = /^\s*[─━]{20,}\s*$/; +// const CCBOT_BIN = "/Users/pakjungeol/.local/bin/ccbot"; // PATH 의존 회피 +// +// export function extractLastTurn(tail) { +// // 1) 마지막 `•` 응답 라인을 anchor (lastResponseIdx) +// // 2) startIdx = 직전 `›` 다음의 첫 `•` (사용자 입력 라인 제외) +// // 3) endIdx = lastResponseIdx 다음으로 가장 가까운 `›`/status_bar/separator 직전 +// } +// +// export async function onHookEvent(event, sdk) { +// if (event.event !== "turn-complete") return; +// const windowName = tmuxWindowName(); +// const tail = capturePaneTail(); +// const turn = extractLastTurn(tail); +// if (turnLines < 2) return; // 너무 짧은 turn skip +// const fp = fingerprint(turn); +// if (fp === await sdk.state.read(`last-fp:${windowName}`)) return; // dedup +// const r = ccbotSend(windowName, `📟 [${windowName}]\n\`\`\`\n${turn}\n\`\`\``); +// if (r.ok) await sdk.state.write(`last-fp:${windowName}`, fp); +// await sdk.log.info?.("...", { ... }); // 모든 분기에 sdk.log.info 로 진단 흔적 +// } +// +// 검증된 노하우: +// - PROMPT_RE 단독 anchor 는 placeholder("› Use /skills..." / "› Implement {feature}")에 +// 잘못 걸려 turn 이 비어 silent skip → RESPONSE_RE(`•`) anchor 로 우회. +// - tmux send-keys 직접 입력은 codex multi-line composer 가 newline 으로 처리 → ccbot +// 본체의 `_send_via_paste` (set-buffer + paste-buffer + Enter) 경로 필수. +// - Hook plugin runner 는 fnm shim PATH 가 안 잡힐 수 있어 CCBOT_BIN 절대경로. +// - 모든 early-return 에 sdk.log.info 흔적 — 0 byte push 디버깅 시 핵심. +``` + +- [ ] **Step 3: omx hook validate** + +```bash +omx hooks validate 2>&1 | tail -20 +``` + +기대: `ccbot-bridge` plugin이 export 검증을 통과. + +- [ ] **Step 4: synthetic 이벤트 테스트** + +```bash +omx hooks test 2>&1 | tail -20 +``` + +기대: turn-complete 이벤트가 plugin에 dispatch되며 에러 없음. (실제로 ccbot send 호출이 일어나도 무시 가능 — 토픽 매핑이 없으면 send.py가 grace fail.) + +- [ ] **Step 5: e2e smoke test (수동)** + +1. cmux 또는 직접 tmux로 `cctmux codex` 진입. +2. omx 부팅 확인 후 텔레그램 codex 토픽에서 첫 메시지로 `ls` 같은 짧은 명령 입력. +3. 첫 입력으로 thread_bindings 자동 매핑 + send-keys로 codex에 전달됨. +4. codex 응답이 끝난 후 Stop hook이 발화 → 토픽에 마지막 capture-pane이 push 되는지 확인. + +기대: 토픽에 `📟 [codex] ...` 메시지가 도착. + +- [ ] **Step 6: state.json에 codex window의 provider 필드가 박히는지 확인** + +```bash +python3 -c " +import json +s = json.load(open('/Users/pakjungeol/.ccbot/state.json')) +for wid, ws in s.get('window_states', {}).items(): + if ws.get('window_name') == 'codex': + print(wid, ws) +" +``` + +자동으로 박히지는 않는다 (Task 1은 모델 추가, 자동 분류 로직은 없음). 필요하면 수동 패치: + +```bash +python3 - <<'PY' +import json, pathlib +p = pathlib.Path.home() / ".ccbot/state.json" +s = json.loads(p.read_text()) +for wid, ws in s.get("window_states", {}).items(): + if ws.get("window_name") == "codex": + ws["provider"] = "codex" +p.write_text(json.dumps(s, indent=2, ensure_ascii=False)) +print("patched") +PY +``` + +- [ ] **Step 7 (옵션): hook plugin 변경분 commit** + +`~/Documents/Claude/.omx/` 가 git 추적 대상이면 commit. 아니면 plan에 위치만 명시. 본 plan의 ccbot-src 커밋과는 무관. + +--- + +## Task 4: 통합 검증 + plan 종료 commit + +- [ ] **Step 1: 폐루프 검증 시나리오** + +| 단계 | 입력/조건 | 기대 결과 | +|---|---|---| +| 1 | `cctmux codex` 진입 | omx가 direct 모드로 부팅, 7번째 창 활성 | +| 2 | 토픽에 `현재 시각 알려줘` 입력 | send-keys로 codex에 전달, codex 응답 생성 | +| 3 | codex 응답 종료 | Stop hook → 토픽에 `📟 [codex] ...` push | +| 4 | 다시 토픽에 `pwd` 입력 | 동일 흐름 — 양방향 폐루프 안정 | +| 5 | claude 토픽 (`claude`/`ceo` 등)에서 평소대로 메시지 | 기존 동작 회귀 없음 | + +- [ ] **Step 2: 회귀 없음 확인 — pytest 전체 한 번 더** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ 2>&1 | tail -10 +``` + +기대: PASS. + +- [ ] **Step 3: ccbot supervisor 카운터가 0인지 마지막 확인** + +```bash +cat ~/.ccbot/.fail-count 2>/dev/null || echo 0 +tail -20 ~/Documents/Claude/logs/ccbot-autostart.log +``` + +기대: count 0, 5분+ 운영 로그. + +- [ ] **Step 4: README 또는 CHANGELOG 업데이트는 yagni — 생략** + +본 plan은 surgical change. 사용자 컨벤션상 README 강제 업데이트 없음. + +- [ ] **Step 5: feature 브랜치 push (공유 브랜치 직접 push 금지)** + +```bash +cd ~/Documents/Personal/ccbot-src +git branch -vv # upstream이 main/dev/prod이면 중단 +git push origin HEAD:ccbot-codex-connect-by-cluade +``` + +- [ ] **Step 6: MR 작성 (squash merge)** + +GitHub UI 또는 `gh pr create`로 PR 생성. 제목: `feat(session): codex/omx provider 연동 (window provider B-lite)`. 본문: 변경 요약, 운영 정책(`OMX_LAUNCH_POLICY=direct`), 외부 파일 변경 위치(`~/.local/scripts/ccbot-start-real.sh`, `~/Documents/Claude/.omx/hooks/`), 검증 시나리오. 머지는 squash. + +--- + +## Backlog (M2 — 1주 사용 후 결정) + +본 plan에서 **의도적으로 제외**한 항목. 실사용 후 fundamentally needed인지 판단: + +1. **Codex rollout JSONL parser** (`~/.codex/sessions/**/rollout-*.jsonl` 모니터링) + - capture-pane이 긴 코드블록·다단 출력을 자르는 경우에만 필요. + - 추가하면 `session_monitor.py`에 provider 분기. +2. **terminal_parser.py codex 호환** + - 현재 STATUS_SPINNERS / UI_PATTERNS는 Claude Code 전용 문자. + - codex window에서 false-trigger가 보이면 provider == codex일 때 우회. +3. **codex hook 자동 등록 (provider 자동 감지)** + - 현재는 codex window의 `provider="codex"` 박는 게 수동 패치. + - omx hook의 SessionStart에서 `state.json`에 자동 시드하는 옵션. +4. **양방향 응답에서 turn 단위 dedup** + - capture-pane 기반은 동일 turn 중복 push 가능성. 1주 사용 후 빈도 보고 결정. +5. **provider 추상화 ABC (`SessionProvider`)** + - M2의 1~3 중 둘 이상이 필요해지는 시점에 도입. 그 전까진 `if provider == "codex"` 분기 1~2개로 충분 (YAGNI). + +--- + +## Self-Review + +- [x] **Spec coverage**: B-lite 핵심 4요소 모두 task로 매핑 — provider 필드(T1), 운영 정책 direct(T2), 응답 라우팅 hook(T3), e2e 검증(T4). +- [x] **Placeholder 없음**: 모든 step에 실제 코드/명령어/기대 출력 포함. +- [x] **Type 일관성**: `provider` 필드 타입(str), default(`"claude"`), 직렬화 조건(`!= "claude"` 시만 포함)을 모든 task에서 동일하게 사용. +- [x] **외부 파일 명시**: `~/.local/scripts/ccbot-start-real.sh`, `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs`는 이 레포 밖이라는 점, 별도 커밋 정책을 plan에 명시. +- [x] **사용자 컨벤션 준수**: 파일명 `2026-05-07-한글주제.md`, 코드 폴더 plans/ 저장, 한글 주제 + 영문 OK 단어 혼용, 하이픈만 구분자. +- [x] **karpathy 4원칙**: + - Think Before Coding: B-lite vs B-full vs A 토론 후 가정 표면화 (단일 tmux 모델, hook 중심). + - Simplicity First: ABC 추상화 회피, 외부 hook으로 본체 결합도 0. + - Surgical Changes: ccbot 본체 수정은 dataclass 1필드. + - Goal-Driven Execution: 검증 시나리오(T4 Step 1)로 성공 기준 사전 정의. + +--- + +## Related + +- 메모리: `reference_ccbot_infra.md` (ccbot 인프라, supervisor, tmux 그룹 모델) +- 외부 스크립트: `~/.local/scripts/ccbot-start-real.sh` +- omx hook plugin 디렉토리: `~/Documents/Claude/.omx/hooks/` +- codex 평행 작업 브랜치: ccbot-src의 다른 브랜치(사용자가 codex CLI에 동시 지시)와는 변경 영역이 겹칠 수 있으므로 머지 시 충돌 검토 필요. diff --git "a/plans/2026-05-08-codex-thinking-status-\352\265\254\355\230\204.md" "b/plans/2026-05-08-codex-thinking-status-\352\265\254\355\230\204.md" new file mode 100644 index 00000000..84f94dcc --- /dev/null +++ "b/plans/2026-05-08-codex-thinking-status-\352\265\254\355\230\204.md" @@ -0,0 +1,567 @@ +# codex thinking status 구현 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** codex window 한 turn 동안 텔레그램 토픽에 in-place edit으로 thinking status 메시지 표시. claude의 spinner UX와 동일한 사용감 + 도구 사용 trace 노출. + +**Architecture:** ccbot 기존 `status_poll_loop` (1초 capture-pane polling) 인프라를 그대로 재사용하고, `terminal_parser.py`에 codex 전용 `parse_codex_status_line` 함수를 신규로 추가, `status_polling.py:109`에서 provider/display_name 분기 한 줄 추가. claude 흐름 100% 보존. + +**Tech Stack:** Python 3.11+, pytest, libtmux. (omx hook 변경 없음.) + +**Spec:** `plans/2026-05-08-codex-thinking-status-알림-design.md` (이 plan은 그 spec의 task 분해) + +--- + +## File Structure + +| 파일 | 변경 | +|---|---| +| `src/ccbot/terminal_parser.py` | `parse_codex_status_line()` 함수 + `STATUS_SPINNERS_CODEX` / `CODEX_TOOL_RE` 상수 추가. 기존 `parse_status_line` 무수정 | +| `src/ccbot/handlers/status_polling.py:109` | `parse_status_line(pane_text)` 호출 직전에 provider 분기 추가 | +| `tests/ccbot/test_terminal_parser.py` | `TestParseCodexStatusLine` 클래스 5 case 추가 | +| `tests/ccbot/test_status_polling_codex.py` | (신규) provider 분기 통합 테스트 1 case | + +--- + +## Task 1: codex thinking 패턴 실측 + +코드 작성 전 실측. 이 task의 산출물은 patterns 자료 (cli 출력) — 다음 task의 fixture/상수 입력값. + +**Files:** 없음 (실측만) + +- [ ] **Step 1: 실측 준비 — codex window 깨끗한 상태 확인** + +```bash +tmux capture-pane -t ccbot:codex -p -S -10 | tail -10 +``` + +기대: 마지막 라인이 `gpt-X.Y high · ... · main` 형식의 status bar. 누적 입력 라인이 많으면 사용자에게 codex window에서 `/clear` 한 번 입력 요청. + +- [ ] **Step 2: 사용자에게 long-thinking 메시지 보내달라고 요청** + +사용자가 텔레그램 codex 토픽에 다음 같은 메시지 보냄 (3종류, 각 turn 마다 1초 단위 capture): + + 1. **즉답형**: `안녕` + 2. **단일 도구**: `/Users/pakjungeol/Documents/Claude의 LICENSE 파일 내용 보여줘` + 3. **장시간 + 다중 도구**: `5초 기다린 후 README 첫 5줄을 출력해줘` + +- [ ] **Step 3: 시계열 capture 자동화** + +다른 터미널에서 1초마다 `tmux capture-pane -t ccbot:codex -p -S -50` 실행하며 stderr로 timestamp 찍기. 30초간: + +```bash +for i in $(seq 1 30); do + echo "=== t=${i}s $(date +%H:%M:%S) ===" >&2 + tmux capture-pane -t ccbot:codex -p -S -50 + echo + sleep 1 +done > /tmp/codex-thinking-trace.txt +``` + +- [ ] **Step 4: trace 분석 — thinking spinner / tool 라인 패턴 추출** + +```bash +# spinner character 또는 thinking text 검출 +grep -E "^[^a-zA-Z]" /tmp/codex-thinking-trace.txt | sort -u | head -30 +# 도구 사용 라인 (• 시작) +grep -E "^\s*•" /tmp/codex-thinking-trace.txt | sort -u | head -30 +``` + +기대: spinner character (예: `⏳`, `▶`, `·`, `…`) 또는 thinking 텍스트 (예: `Working`, `Thinking for Xs`, `Generating`). 도구 라인은 `• Ran`, `• Read`, `• Edit`, `• Wrote`, `• Explored` 같은 동사로 시작. + +- [ ] **Step 5: 결과 정리 (다음 task에 박을 상수)** + +다음 형식으로 결과를 정리해서 plan에 주석으로 남긴다: + +``` +STATUS_SPINNERS_CODEX (실측): + - 검출 character/string: ... + - working 텍스트 prefix: ... + +CODEX_TOOL_RE (실측): + - 시작 verb 집합: Ran, Read, Edit, Wrote, Explored, ... +``` + +만약 thinking 패턴을 capture-pane에서 전혀 못 찾으면 **fallback design 활성화** — spec의 "Open question" 섹션 마지막 단락 참조 (omx hook 기반 status_update CLI 신설). 이 경우 본 plan 일시 중단하고 fallback plan 별도 작성. + +- [ ] **Step 6: 실측 결과 노트 commit** + +```bash +cd ~/Documents/Personal/ccbot-src +mkdir -p tests/ccbot/fixtures +cp /tmp/codex-thinking-trace.txt tests/ccbot/fixtures/codex_thinking_trace.txt +git add tests/ccbot/fixtures/codex_thinking_trace.txt +git commit -m "test(fixtures): codex thinking 패턴 시계열 capture (실측) + +다음 task의 parse_codex_status_line fixture / 상수 입력값으로 사용." +``` + +--- + +## Task 2: parse_codex_status_line 함수 추가 (TDD) + +**Files:** +- Modify: `src/ccbot/terminal_parser.py` +- Test: `tests/ccbot/test_terminal_parser.py` + +T1 산출물(`fixtures/codex_thinking_trace.txt`)에서 추출한 패턴을 상수로 박는다. + +- [ ] **Step 1: T1 결과로부터 상수 값 결정** + +T1 step 5의 분석 결과를 보고 다음 두 상수의 정확한 값을 결정. 본 plan 작성 시점에는 placeholder 값을 두었으니 실측 후 교체. + +```python +# 예시 — T1 실측으로 교체할 것 +STATUS_SPINNERS_CODEX: frozenset[str] = frozenset(["⏳", "▶", "…"]) # T1 step 5 산출 +CODEX_TOOL_RE = re.compile(r"^\s*•\s+(Ran|Read|Edit|Wrote|Explored|Searched)\b") # T1 step 5 +``` + +- [ ] **Step 2: failing test 작성** + +`tests/ccbot/test_terminal_parser.py`에 새 클래스 추가: + +```python +# tests/ccbot/test_terminal_parser.py 끝에 추가 + +from ccbot.terminal_parser import parse_codex_status_line + + +class TestParseCodexStatusLine: + def test_thinking_spinner_returns_status(self) -> None: + """spinner character + working text 라인이 있으면 그 텍스트 반환.""" + # 실측 사례를 fixture로 사용 — T1 트레이스에서 thinking 시점 라인 발췌 + pane = ( + "› 5초 기다린 후 README 출력\n" + "⏳ Working 3s\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + result = parse_codex_status_line(pane) + assert result is not None + assert "Working" in result or "⏳" in result + + def test_tool_use_line_returns_status(self) -> None: + """thinking spinner 없을 때 가장 최근 도구 사용 라인 반환.""" + pane = ( + "› LICENSE 보여줘\n" + "• Read LICENSE\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + result = parse_codex_status_line(pane) + assert result is not None + assert "Read" in result + + def test_idle_returns_none(self) -> None: + """status bar만 있고 thinking/trace 없으면 None.""" + pane = ( + "› Use /skills to list available skills\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + assert parse_codex_status_line(pane) is None + + def test_status_bar_filtered_out(self) -> None: + """status bar 라인이 결과에 포함되지 않는다.""" + pane = ( + "• Ran echo hello\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + result = parse_codex_status_line(pane) + assert result is not None + assert "gpt-5.5" not in result + + def test_empty_pane_returns_none(self) -> None: + assert parse_codex_status_line("") is None + assert parse_codex_status_line("\n\n\n") is None +``` + +- [ ] **Step 3: 테스트 실행 — fail 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ccbot/test_terminal_parser.py::TestParseCodexStatusLine -v 2>&1 | tail -15 +``` + +기대: 5개 모두 FAIL (`ImportError: cannot import name 'parse_codex_status_line'` 또는 그 유사). + +- [ ] **Step 4: parse_codex_status_line 구현** + +`src/ccbot/terminal_parser.py`의 기존 `STATUS_SPINNERS = frozenset([...])` 상수 직후에 추가: + +```python +# codex TUI status patterns (실측 fixture: tests/ccbot/fixtures/codex_thinking_trace.txt). +# claude의 STATUS_SPINNERS와 별도로 둔다 — codex의 spinner/tool 표시는 별개 어휘. +STATUS_SPINNERS_CODEX: frozenset[str] = frozenset(["⏳", "▶", "…"]) # T1 실측 결과로 교체 +CODEX_TOOL_RE = re.compile(r"^\s*•\s+(Ran|Read|Edit|Wrote|Explored|Searched)\b") +CODEX_STATUS_BAR_RE = re.compile(r"^\s*gpt-[\d.]+(?:\s+\w+)?\s+·") +CODEX_TOOL_LINE_MAX = 100 # status 메시지에 포함할 도구 라인 길이 상한 +``` + +같은 파일의 `parse_status_line` 함수 직후에 신규 함수 추가: + +```python +def parse_codex_status_line(pane_text: str) -> str | None: + """codex window의 capture-pane에서 thinking status 한 줄 추출. + + 우선순위: + 1) thinking spinner (STATUS_SPINNERS_CODEX의 문자로 시작) → "⏳ " + 2) 가장 최근 `• ...` 도구 사용 라인 → "🔧 <라인>" + 3) 둘 다 없음 → None (idle 상태) + + status bar 라인(`gpt-X.Y ...`)은 결과에서 제외한다. + + Args: + pane_text: capture_pane(...) 결과 문자열. + + Returns: + status 한 줄 텍스트 (앞뒤 공백 제거, 최대 CODEX_TOOL_LINE_MAX자) 또는 None. + """ + if not pane_text: + return None + + lines = pane_text.split("\n") + # 마지막에서 역순 스캔. status bar 라인은 건너뛴다. + last_tool: str | None = None + for line in reversed(lines): + stripped = line.strip() + if not stripped: + continue + if CODEX_STATUS_BAR_RE.match(line): + continue + # 우선순위 1: spinner + if stripped[0] in STATUS_SPINNERS_CODEX: + return stripped[:CODEX_TOOL_LINE_MAX] + # 우선순위 2 후보 — 가장 최근(역순 스캔의 첫번째) 도구 라인 보관 + if last_tool is None and CODEX_TOOL_RE.match(line): + last_tool = stripped[:CODEX_TOOL_LINE_MAX] + # 계속 스캔 — 위쪽에 spinner가 있으면 우선순위 1 우선 + if last_tool is not None: + return f"🔧 {last_tool}" + return None +``` + +- [ ] **Step 5: 테스트 실행 — pass 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ccbot/test_terminal_parser.py::TestParseCodexStatusLine -v 2>&1 | tail -15 +``` + +기대: 5개 모두 PASS. + +- [ ] **Step 6: 전체 회귀 테스트** + +```bash +uv run pytest tests/ -q 2>&1 | tail -5 +``` + +기대: 전체 PASS (claude `parse_status_line` 회귀 없음). + +- [ ] **Step 7: commit** + +```bash +git add src/ccbot/terminal_parser.py tests/ccbot/test_terminal_parser.py +git commit -m "$(cat <<'EOF' +feat(parser): add parse_codex_status_line for codex thinking status + +claude의 parse_status_line은 무수정으로 보존하고 codex 전용 함수를 +별도로 추가. STATUS_SPINNERS_CODEX/CODEX_TOOL_RE 상수는 T1 실측 +fixture 기반. + +우선순위: + 1) thinking spinner character → "⏳ " + 2) 가장 최근 도구 사용 라인 → "🔧 " + 3) 둘 다 없으면 None (idle) + +status bar 라인은 결과에서 제외. +EOF +)" +``` + +--- + +## Task 3: status_polling.py에 provider 분기 + +**Files:** +- Modify: `src/ccbot/handlers/status_polling.py:71-119` +- Test: `tests/ccbot/test_status_polling_codex.py` (신규) + +- [ ] **Step 1: failing 통합 테스트 작성** + +신규 파일 `tests/ccbot/test_status_polling_codex.py`: + +```python +"""Integration test for codex provider routing in status_polling.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers.status_polling import update_status_message +from ccbot.session import SessionManager, WindowState + + +@pytest.fixture +def mgr(monkeypatch) -> SessionManager: + monkeypatch.setattr(SessionManager, "_load_state", lambda self: None) + monkeypatch.setattr(SessionManager, "_save_state", lambda self: None) + return SessionManager() + + +@pytest.mark.asyncio +async def test_update_status_routes_codex_window_to_codex_parser( + mgr: SessionManager, monkeypatch +) -> None: + """codex provider window는 parse_codex_status_line으로 분기.""" + # codex provider window 세팅 + ws = WindowState(provider="codex", cwd="/x", window_name="codex") + mgr.window_states["@27"] = ws + monkeypatch.setattr("ccbot.handlers.status_polling.session_manager", mgr) + + # tmux_manager mock + fake_window = MagicMock(window_id="@27") + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.find_window_by_id", + AsyncMock(return_value=fake_window), + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.capture_pane", + AsyncMock(return_value="› hi\n• Read X\n gpt-5.5 high · main\n"), + ) + + # parse 함수 둘 다 spy로 wrap — 어느 게 호출됐는지 검증 + claude_parser = MagicMock(return_value=None) + codex_parser = MagicMock(return_value="🔧 Read X") + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_status_line", claude_parser + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_codex_status_line", codex_parser + ) + + enqueue = AsyncMock() + monkeypatch.setattr( + "ccbot.handlers.status_polling.enqueue_status_update", enqueue + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.is_interactive_ui", lambda _t: False + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.get_interactive_window", lambda _u, _t: None + ) + + bot = MagicMock() + await update_status_message(bot, user_id=1, window_id="@27", thread_id=42) + + codex_parser.assert_called_once() + claude_parser.assert_not_called() + enqueue.assert_awaited_once() + # 4번째 positional arg가 status_line + args = enqueue.await_args.args + assert args[3] == "🔧 Read X" + + +@pytest.mark.asyncio +async def test_update_status_routes_claude_window_to_claude_parser( + mgr: SessionManager, monkeypatch +) -> None: + """기본(claude) provider는 기존 parse_status_line 흐름.""" + ws = WindowState(provider="claude", cwd="/x", window_name="claude") + mgr.window_states["@5"] = ws + monkeypatch.setattr("ccbot.handlers.status_polling.session_manager", mgr) + + fake_window = MagicMock(window_id="@5") + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.find_window_by_id", + AsyncMock(return_value=fake_window), + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.capture_pane", + AsyncMock(return_value="✻ Sautéed for 5s · 1 shell still running\n"), + ) + + claude_parser = MagicMock(return_value="✻ Sautéed for 5s") + codex_parser = MagicMock(return_value=None) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_status_line", claude_parser + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_codex_status_line", codex_parser + ) + + enqueue = AsyncMock() + monkeypatch.setattr( + "ccbot.handlers.status_polling.enqueue_status_update", enqueue + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.is_interactive_ui", lambda _t: False + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.get_interactive_window", lambda _u, _t: None + ) + + bot = MagicMock() + await update_status_message(bot, user_id=1, window_id="@5", thread_id=42) + + claude_parser.assert_called_once() + codex_parser.assert_not_called() +``` + +- [ ] **Step 2: 테스트 실행 — fail 확인** + +```bash +cd ~/Documents/Personal/ccbot-src +uv run pytest tests/ccbot/test_status_polling_codex.py -v 2>&1 | tail -15 +``` + +기대: 첫 번째 case FAIL — `parse_codex_status_line` import 실패 또는 분기 안 됨. 두 번째 case는 PASS 가능 (claude 흐름은 기존 그대로라). + +- [ ] **Step 3: status_polling.py에 provider 분기 추가** + +`src/ccbot/handlers/status_polling.py` 변경 두 군데: + +(a) import 추가 (파일 상단의 기존 import 블록): + +```python +from ..terminal_parser import ( + is_interactive_ui, + parse_codex_status_line, + parse_status_line, +) +``` + +(b) `update_status_message` 함수 안 line 109 부근 — `status_line = parse_status_line(pane_text)` 한 줄을 다음으로 교체: + +```python + # provider 분기: codex window는 별도 status 추출 함수. + # WindowState.provider 가 authoritative; 없으면 display_name == "codex" fallback + # (codex window는 SessionStart hook 자동 등록 경로가 없어 window_states 가 빌 수 있음). + ws = session_manager.window_states.get(window_id) + display = session_manager.get_display_name(window_id) + is_codex = (ws and ws.provider == "codex") or display == "codex" + status_line = ( + parse_codex_status_line(pane_text) + if is_codex + else parse_status_line(pane_text) + ) +``` + +- [ ] **Step 4: 테스트 실행 — pass 확인** + +```bash +uv run pytest tests/ccbot/test_status_polling_codex.py -v 2>&1 | tail -15 +``` + +기대: 2개 모두 PASS. + +- [ ] **Step 5: 전체 회귀 테스트** + +```bash +uv run pytest tests/ -q 2>&1 | tail -5 +``` + +기대: 전체 PASS. + +- [ ] **Step 6: commit** + +```bash +git add src/ccbot/handlers/status_polling.py tests/ccbot/test_status_polling_codex.py +git commit -m "$(cat <<'EOF' +feat(status): codex provider routing in update_status_message + +WindowState.provider == 'codex' (또는 display_name == 'codex' fallback) +일 때 parse_codex_status_line 으로 분기. claude 흐름은 기존 그대로. + +provider 결정 로직은 SessionManager.send_to_window 와 동일 (M1 의 paste +경로 분기와 일관). +EOF +)" +``` + +--- + +## Task 4: 통합 검증 + push + +**Files:** 없음 (검증만 + push) + +- [ ] **Step 1: ccbot 프로세스 reload** + +editable install이라 코드 변경은 즉시 반영되지만, 이미 import된 모듈은 메모리에 옛 버전. ccbot 재시작: + +```bash +pkill -HUP -f "ccbot start" +sleep 3 +ps aux | grep "ccbot start" | grep -v grep | head -2 +``` + +기대: 새 PID로 ccbot 재시작. + +- [ ] **Step 2: e2e — 텔레그램 in-place edit 동작 확인** + +사용자가 텔레그램 codex 토픽에 다음 메시지 입력: + +``` +5초 기다린 후 README 첫 5줄을 보여줘 +``` + +기대 흐름: +1. ccbot 토픽에 새 status 메시지 push (`⏳ Working ...` 또는 `🔧 Ran sleep 5`). +2. 1초 간격으로 in-place edit (메시지 ID 유지, 본문 갱신 — `🔧 Read README.md` 등으로 진행). +3. codex 응답 완료 시 status 메시지 사라지고 응답 본문이 별도 메시지로 도착 (M1 omx hook 흐름). + +육안 확인: +- status 메시지가 새로 N개 쌓이지 않고 1개만 in-place edit 되는지 +- 응답 도착 후 status 메시지가 깔끔히 사라지는지 +- 도구 사용 라인이 trace로 보이는지 + +- [ ] **Step 3: claude 흐름 회귀 — 변화 없음 확인** + +사용자가 텔레그램 claude 토픽 (또는 ceo/metlife 등)에 평소대로 메시지 입력. claude 흐름은 기존 그대로 (✻ Sautéed for Xs · ... 형식 in-place edit) 작동해야 함. + +기대: claude window에 회귀 없음, 평소와 동일한 spinner status. + +- [ ] **Step 4: ccbot 로그 sanity check** + +```bash +tail -30 ~/Documents/Claude/logs/ccbot-autostart.log | grep -iE "error|traceback|exception" || echo "no errors" +``` + +기대: `no errors`. + +- [ ] **Step 5: feature 브랜치 push (공유 브랜치 직접 push 금지 — push-guard)** + +```bash +cd ~/Documents/Personal/ccbot-src +git branch -vv # upstream이 main/dev/prod이면 중단 +git push origin HEAD:ccbot-codex-connect-by-cluade +``` + +기대: PR #3 자동 갱신 (Task 1~3 commits 추가). + +- [ ] **Step 6: PR 본문 backlog 항목 — 본 plan 머지 표시** + +PR #3 본문의 Backlog 섹션에 본 plan 완료 표시: + +```markdown +- [x] codex thinking status in-place 알림 (plans/2026-05-08-codex-thinking-status-구현.md) +``` + +`gh pr edit 3 --repo TejNote/ccbot --body-file ` 또는 GitHub UI에서 직접 편집. + +--- + +## Self-Review + +- [x] **Spec coverage**: spec의 4개 핵심 결정 — Architecture(B-lite + status_polling), parse_codex_status_line 우선순위, Data Flow, File Structure — 각각 Task 2/3에 매핑됨. spec의 "Open question(실측)"은 Task 1에 명시. +- [x] **Placeholder 없음**: 모든 step에 실제 코드 / 명령 / 기대 출력 포함. T1의 `STATUS_SPINNERS_CODEX` 값은 placeholder가 아니라 의도적 실측 입력값 — T2 step 1에서 교체 명시. +- [x] **Type 일관성**: `parse_codex_status_line(pane_text: str) -> str | None`, `STATUS_SPINNERS_CODEX: frozenset[str]`, `CODEX_TOOL_RE: re.Pattern` — Task 2/3 전체에서 동일 사용. +- [x] **Provider 분기 일관**: `(ws and ws.provider == "codex") or display == "codex"` 패턴이 spec / Task 3 / 기존 `session.py:send_to_window` (M1)와 동일. +- [x] **회귀 보호**: claude `parse_status_line` 무수정 + 분기 테스트로 회귀 케이스(`test_update_status_routes_claude_window_to_claude_parser`) 명시. + +--- + +## Related + +- spec: `plans/2026-05-08-codex-thinking-status-알림-design.md` +- M1 plan: `plans/2026-05-07-codex-omx-ccbot-연동.md` (양방향 폐루프) +- 참조 코드: + - `src/ccbot/handlers/status_polling.py:46-119` (`update_status_message` 흐름) + - `src/ccbot/terminal_parser.py:199` (`STATUS_SPINNERS`, `parse_status_line`) + - `src/ccbot/session.py:854-887` (`send_to_window` provider 분기 — Task 3과 동일 패턴) diff --git "a/plans/2026-05-08-codex-thinking-status-\354\225\214\353\246\274-design.md" "b/plans/2026-05-08-codex-thinking-status-\354\225\214\353\246\274-design.md" new file mode 100644 index 00000000..16970682 --- /dev/null +++ "b/plans/2026-05-08-codex-thinking-status-\354\225\214\353\246\274-design.md" @@ -0,0 +1,177 @@ +# codex thinking status 텔레그램 in-place 알림 — Design Spec + +**Goal:** codex window 한 turn (사용자 입력 → 응답 완료) 동안 텔레그램 토픽에 "생각중" status 메시지를 in-place edit으로 표시. claude window의 spinner UX (`✻ Sautéed for 5s · 1 shell still running`)와 동일한 사용감을 codex에도 제공. 도구 사용(Read X, Ran ...) trace까지 단계별로 노출. + +**Context:** 본 spec은 `2026-05-07-codex-omx-ccbot-연동.md` plan(양방향 폐루프 = M1)의 후속 M2 단계. M1에서 의도적으로 backlog로 둔 "thinking 알림 push" 항목을 deep dive. + +**Non-goals (M3+):** +- codex 외 다른 provider(gemini/qwen) 일반화 — `SessionProvider` ABC 도입은 셋 이상 provider 필요해질 때. +- 실시간 streaming partial response — omx native streaming 활용 시 별도 plan. +- claude의 status_msg와 codex status_msg 동작 차이 — 본 spec은 "claude UX와 동일"이 목표. + +--- + +## Architecture (B-lite + status_polling 확장) + +기존 인프라를 최대한 재사용하는 surgical 변경: + +| 기존 인프라 | 재사용 방식 | +|---|---| +| `status_poll_loop` (1초 polling) | thread-bound window 전체를 이미 iterate 중 → codex window도 자동 포함 | +| `update_status_message` | provider 분기 추가만 — codex면 다른 parse 함수 호출 | +| `enqueue_status_update` / `_do_clear_status_message` | 그대로 사용 (in-place edit, 정리 메커니즘 동일) | +| `status_msg_ids` 영속화 | 그대로 사용 (재시작 시 orphan 정리 자동) | +| `tmux_manager.capture_pane` | 그대로 사용 (codex window도 동일 capture 가능) | +| `parse_status_line` (claude 전용) | 그대로 두고 옆에 `parse_codex_status_line` 신규 | + +**핵심 결정**: `terminal_parser.py`에 codex 전용 함수를 신규로 추가 (claude의 `parse_status_line`은 무수정). `status_polling.py`에서 `WindowState.provider == "codex"` 또는 `display_name == "codex"`로 분기. 이렇게 하면 claude 흐름은 100% 보존. + +--- + +## parse_codex_status_line 동작 + +신규 함수 시그니처: + +```python +def parse_codex_status_line(pane_text: str) -> str | None: + """codex window의 capture-pane에서 thinking status 한 줄 추출. + + Returns: + - "⏳ Working 3s" 같은 string: 텔레그램 status 메시지로 표시 + - None: 패턴 못 찾음 (idle 상태) — status 표시 안 함, 기존 status 메시지 있으면 cleanup 트리거 + """ +``` + +알고리즘 (claude의 `parse_status_line`과 대칭 구조): + +``` +1) capture-pane을 라인 단위로 split, 마지막에서 역순 스캔. +2) status bar 라인 (`gpt-X.Y high · ...`) 직전까지가 검사 영역. +3) 검사 영역에서 다음 우선순위로 status 추출: + a) thinking spinner 패턴 (예: `⏳ Working 5s` / `▶ Generating ...`) + → "⏳ " 반환. 해당 패턴 상수는 plan 단계에서 실측 후 STATUS_SPINNERS_CODEX에 박는다. + b) 가장 최근 `• Ran X` / `• Read Y` / `• Explored` 도구 사용 라인 + → "🔧 <라인 1줄>" 반환 (한 줄 trim, 100자 제한) + c) 둘 다 없으면 None (turn 끝났거나 prompt placeholder만 있는 idle 상태) +``` + +상수: +- `STATUS_SPINNERS_CODEX: frozenset[str]` — codex spinner character 집합 (실측). 빈 세트로 시작해도 (b) trace 표시는 동작. +- `CODEX_TOOL_RE: re.Pattern` — `^\s*•\s+(Ran|Read|Edit|Wrote|Explored|...)` 매칭 (실측 후 정리). + +--- + +## Data Flow + +``` +사용자 → 텔레그램 codex 토픽 입력 + ↓ +ccbot text_handler → session.send_to_window (paste 경로 — 이미 동작 중) + ↓ +codex가 응답 생성 시작 (응답 들어가는 사이 capture-pane 변화) + +[병행] status_poll_loop 1초 tick + ├─ thread_bindings의 codex window 발견 + ├─ capture_pane(window) + ├─ provider 분기: codex + ├─ parse_codex_status_line(pane_text) + │ + ├─ → "⏳ Working 2s" (1번째 tick) → enqueue_status_update → 토픽에 status 메시지 생성 + ├─ → "🔧 Read SKILL.md" (3번째 tick) → enqueue_status_update → in-place edit (같은 메시지 갱신) + ├─ → "🔧 Ran sleep 10" (5번째 tick) → enqueue_status_update → in-place edit + ├─ → ... (status 메시지 1개가 매 tick 내용 업데이트) + │ +turn-complete (omx hook firing) + └─ ccbot-bridge.mjs → ccbot send (응답 push, M1 그대로) + +[병행] status_poll_loop 그 다음 tick + ├─ capture_pane (이제 turn 끝나서 codex idle) + ├─ parse_codex_status_line → None + └─ _do_clear_status_message → status 메시지 삭제 + +결과: 토픽에 status 메시지 1개가 turn 동안 in-place로 진행 표시 → turn 끝나면 사라지고 응답 메시지가 별도 push됨. +``` + +claude 흐름과 정확히 동일 — 차이는 parse 함수 한 개뿐. + +--- + +## File Structure + +| 파일 | 변경 | 책임 | +|---|---|---| +| `src/ccbot/terminal_parser.py` | 추가 ~30~50라인 | `parse_codex_status_line()` 함수 + 패턴 상수. 기존 `parse_status_line` 무수정 | +| `src/ccbot/handlers/status_polling.py` | 추가 ~5~10라인 | `update_status_message`에서 provider/display_name 분기 | +| `tests/ccbot/test_terminal_parser.py` | 추가 ~30라인 | 단위 테스트 — 실측 capture fixture로 thinking/trace/idle 시나리오 | +| `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs` | 무변경 | 기존 turn-complete push 흐름 그대로 유지 | +| `plans/2026-05-08-codex-thinking-status-알림-design.md` | 신규 (이 문서) | spec | + +분리 근거: claude의 `parse_status_line`을 건드리면 회귀 위험. 별도 함수로 두면 claude 사용자 환경 100% 보존 + codex 분기는 명확한 if 한 줄. + +--- + +## Error Handling + +| 시나리오 | 처리 | +|---|---| +| `capture_pane`이 None/빈 문자열 | claude와 동일 — silent skip (`update_status_message` 기존 분기) | +| `parse_codex_status_line` → None | status 표시 안 함. 기존 status 메시지 있으면 `_do_clear_status_message`로 정리 (즉 idle 진입 시 자동 cleanup) | +| codex가 응답 중 에러로 죽음 | turn-complete hook 안 옴 → status 메시지 cleanup만 polling으로 처리 (timeout 같은 추가 로직 없음 — claude도 동일) | +| 사용자가 codex window를 직접 detach 후 재attach | tmux pane id 변경 가능. 기존 ccbot stale pane 처리 로직(`Removing stale window_state`) 그대로 동작 | +| `STATUS_SPINNERS_CODEX` 패턴 미스매치 | 도구 trace로 fallback. 둘 다 미스매치면 None — 일시적 빈 status는 허용 (다음 tick에 catch up) | + +--- + +## Testing + +**단위 테스트** (`tests/ccbot/test_terminal_parser.py`): + +1. `test_parse_codex_status_thinking` — capture fixture에 spinner 텍스트 있을 때 "⏳ Working Xs" 반환. +2. `test_parse_codex_status_tool_use` — 마지막 `•` 라인이 도구 사용일 때 "🔧 Read X" 반환. +3. `test_parse_codex_status_idle` — status bar만 있고 thinking/trace 없을 때 None 반환. +4. `test_parse_codex_status_status_bar_filtered` — status bar 라인이 결과에 포함되지 않음 (filter 검증). +5. `test_parse_status_line_unchanged_for_claude` — 기존 claude `parse_status_line` 호출 그대로 → 회귀 없음. + +**통합 검증** (수동 — plan 단계): +- 사용자가 codex 토픽에 다양한 메시지 입력 (즉답형, 도구 사용, 긴 thinking 등) +- 텔레그램에서 status 메시지가 in-place edit되며 trace가 갱신되는지 육안 확인 +- turn 완료 후 status 메시지가 사라지고 응답이 별도로 도착하는지 확인 + +--- + +## Open question (plan 단계 실측) + +**codex thinking 패턴이 정확히 무엇인지** — 본 spec은 "spinner 또는 도구 trace를 status에 표시"까지만 정한다. 실제 spinner character / 텍스트 형식은 plan T1 첫 step에서 실측: + +``` +사용자가 codex 토픽에 "10초 기다린 후 안녕이라고 답해줘" 같은 메시지 입력 + → 그 동안 1초 간격으로 capture-pane 시계열 캡처 + → 어떤 라인에 어떤 문자가 보이는지 fixture로 추출 + → STATUS_SPINNERS_CODEX, CODEX_TOOL_RE 채우기 +``` + +이 실측이 spec의 "구현 가능성"을 깨면 (= codex가 thinking 표시를 화면에 안 그림) → fallback design을 쓴다: +- omx hook의 `pre-tool-use` / `post-tool-use` 이벤트로 ccbot에 status_update 신호를 직접 보냄 (`ccbot status-update --window codex --text "..."` CLI 신규) +- 즉 capture-pane 의존을 omx hook 의존으로 교체 + +이 fallback은 폼 좀 큰 작업이므로 plan 단계 실측 결과 본 후 결정. + +--- + +## Self-Review + +- **Placeholder:** "TBD"/"TODO" 없음. 단 "Open question — 실측" 섹션은 의도적인 plan 단계 작업으로 명시. +- **Internal consistency:** Architecture 표 / Data Flow 다이어그램 / File Structure가 모두 같은 결정을 표현 (provider 분기는 status_polling.py, parse는 terminal_parser.py). +- **Scope:** 단일 plan 분량 (parse 함수 + 분기 + 테스트). codex 외 provider, streaming, 별도 hook fallback은 모두 Out-of-scope 또는 conditional fallback. +- **Ambiguity:** `parse_codex_status_line` 우선순위 (a > b > c) 명시. STATUS_SPINNERS_CODEX 빈 세트로 시작해도 (b) trace path만으로 동작 보장 — 점진적 정확도 향상 path 명확. + +--- + +## Related + +- M1 plan: `plans/2026-05-07-codex-omx-ccbot-연동.md` (양방향 폐루프, PR #3에서 머지 대기) +- claude 참조 코드: + - `src/ccbot/handlers/status_polling.py:46-100` (`update_status_message` 흐름) + - `src/ccbot/terminal_parser.py:199` (`STATUS_SPINNERS`, `parse_status_line`) + - `src/ccbot/handlers/message_queue.py` (`_do_clear_status_message`, `enqueue_status_update`) +- 메모리: `reference_ccbot_infra.md` (ccbot 인프라 / status_msg_ids 영속화) diff --git a/pyproject.toml b/pyproject.toml index 0d476088..f22d0ec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,3 +58,10 @@ exclude_lines = [ "if __name__ == .__main__.", "logger\\.", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.1.0", +] diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 22d78d86..213bea46 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -136,7 +136,10 @@ from .message_batcher import batcher from .session import session_manager from .session_monitor import NewMessage, SessionMonitor -from .terminal_parser import extract_bash_output, is_interactive_ui +from .terminal_parser import ( + extract_bash_output, + is_interactive_ui, +) from .tmux_manager import tmux_manager from .transcribe import close_client as close_transcribe_client from .transcribe import transcribe_voice diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index d10967e2..c743206f 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -21,7 +21,7 @@ import logging import time from dataclasses import dataclass, field -from typing import Literal +from typing import Any, Literal from telegram import Bot from telegram.constants import ChatAction @@ -123,12 +123,14 @@ def get_or_create_queue( return _message_queues[user_id] -def _inspect_queue(queue: asyncio.Queue[MessageTask]) -> list[MessageTask]: +def _inspect_queue( + queue: asyncio.Queue[MessageTask | DirectMessage], +) -> list[MessageTask | DirectMessage]: """Non-destructively inspect all items in queue. Drains the queue and returns all items. Caller must refill. """ - items: list[MessageTask] = [] + items: list[MessageTask | DirectMessage] = [] while not queue.empty(): try: item = queue.get_nowait() @@ -155,7 +157,7 @@ def _can_merge_tasks(base: MessageTask, candidate: MessageTask) -> bool: async def _merge_content_tasks( - queue: asyncio.Queue[MessageTask], + queue: asyncio.Queue[MessageTask | DirectMessage], first: MessageTask, lock: asyncio.Lock, ) -> tuple[MessageTask, int]: @@ -176,9 +178,12 @@ async def _merge_content_tasks( async with lock: items = _inspect_queue(queue) - remaining: list[MessageTask] = [] + remaining: list[MessageTask | DirectMessage] = [] for i, task in enumerate(items): + if not isinstance(task, MessageTask): + remaining = items[i:] + break if not _can_merge_tasks(first, task): # Can't merge, keep this and all remaining items remaining = items[i:] @@ -299,7 +304,7 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: logger.error(f"Unexpected error in queue worker for user {user_id}: {e}") -def _send_kwargs(thread_id: int | None) -> dict[str, int]: +def _send_kwargs(thread_id: int | None) -> dict[str, Any]: """Build message_thread_id kwargs for bot.send_message().""" if thread_id is not None: return {"message_thread_id": thread_id} diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index c4de1c6e..67ecbecf 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -24,7 +24,11 @@ from telegram.error import BadRequest from ..session import session_manager -from ..terminal_parser import is_interactive_ui, parse_status_line +from ..terminal_parser import ( + is_interactive_ui, + parse_codex_status_line, + parse_status_line, +) from ..tmux_manager import tmux_manager from .interactive_ui import ( clear_interactive_msg, @@ -106,7 +110,12 @@ async def update_status_message( if skip_status: return - status_line = parse_status_line(pane_text) + ws = session_manager.window_states.get(window_id) + display = session_manager.get_display_name(window_id) + is_codex = (ws is not None and ws.provider == "codex") or display == "codex" + status_line = ( + parse_codex_status_line(pane_text) if is_codex else parse_status_line(pane_text) + ) if status_line: await enqueue_status_update( diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index 8a1a44b1..81c5d6a2 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -197,7 +197,8 @@ def hook_main() -> None: try: ps_out = subprocess.run( ["ps", "-o", "args=", "-p", str(os.getppid())], - capture_output=True, text=True, + capture_output=True, + text=True, ).stdout.strip() cmdline = ps_out except Exception: diff --git a/src/ccbot/send.py b/src/ccbot/send.py new file mode 100644 index 00000000..0275ad00 --- /dev/null +++ b/src/ccbot/send.py @@ -0,0 +1,160 @@ +"""Send subcommand — delivers one message to a bound Telegram topic. + +Called by lightweight hook/bridge scripts when the full Telegram bot is already +running and owns `~/.ccbot/state.json`. Routing can target a Claude session ID or +a tmux window display name such as `codex`. + +This module intentionally avoids importing config.py at module import time: hook +processes may not have the bot environment loaded. It reads `~/.ccbot/.env` only +inside `send_main()`. +""" + +import argparse +import json +import logging +import sys +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + + +def _load_env(env_file: Path) -> dict[str, str]: + """Parse simple KEY=VALUE lines from a dotenv file.""" + env: dict[str, str] = {} + if not env_file.exists(): + return env + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip().strip("\"'") + return env + + +def _resolve_routing( + state_file: Path, session_id: str, window_name: str +) -> tuple[int, int] | None: + """Resolve `(chat_id, thread_id)` from a session ID or window name.""" + if not state_file.exists(): + return None + try: + state = json.loads(state_file.read_text()) + except (json.JSONDecodeError, OSError) as e: + logger.error("Failed to read state file %s: %s", state_file, e) + return None + + window_id: str | None = None + for wid, ws in state.get("window_states", {}).items(): + if session_id and ws.get("session_id") == session_id: + window_id = wid + break + if window_name and ws.get("window_name") == window_name: + window_id = wid + break + if not window_id and window_name: + # window_display_names 는 ccbot 재기동 후에도 옛 window_id 가 + # 잔존할 수 있다 (예: kickstart 로 codex 가 @27 → @6 으로 재 cut + # 됐는데 옛 @27 매핑이 남는 경우). 그 stale 항목을 잡으면 + # thread_bindings 에서 못 찾아 silent fail. thread_bindings 에 + # 실제 매핑된 window_id 만 fallback 후보로 삼는다 (claude 브랜치 흡수). + bound_window_ids: set[str] = set() + for bindings in state.get("thread_bindings", {}).values(): + bound_window_ids.update(bindings.values()) + for wid, display_name in state.get("window_display_names", {}).items(): + if display_name == window_name and wid in bound_window_ids: + window_id = wid + break + + if not window_id: + logger.debug( + "No window found for session_id=%r window_name=%r", session_id, window_name + ) + return None + + user_id: str | None = None + thread_id: int | None = None + for uid, bindings in state.get("thread_bindings", {}).items(): + for tid, wid in bindings.items(): + if wid == window_id: + user_id = uid + thread_id = int(tid) + break + if thread_id is not None: + break + + if user_id is None or thread_id is None: + logger.debug("No thread binding found for window_id=%s", window_id) + return None + + chat_id = state.get("group_chat_ids", {}).get(f"{user_id}:{thread_id}") + if chat_id is None: + logger.debug("No group_chat_id for user=%s thread=%s", user_id, thread_id) + return None + + return int(chat_id), thread_id + + +def send_main() -> None: + """Entry point for `ccbot send`.""" + logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + level=logging.INFO, + stream=sys.stderr, + ) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + parser = argparse.ArgumentParser( + prog="ccbot send", + description="Send a message to the Telegram topic for a session/window", + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--session-id", metavar="ID", help="Claude/Codex session ID") + group.add_argument("--window", metavar="NAME", help="tmux window name") + parser.add_argument("message", help="Message text to send") + args = parser.parse_args(sys.argv[2:]) + + from .utils import ccbot_dir + + config_dir = ccbot_dir() + bot_token = _load_env(config_dir / ".env").get("TELEGRAM_BOT_TOKEN", "") + if not bot_token: + logger.error("TELEGRAM_BOT_TOKEN not found in %s/.env", config_dir) + sys.exit(1) + + routing = _resolve_routing( + config_dir / "state.json", + session_id=args.session_id or "", + window_name=args.window or "", + ) + if not routing: + logger.error( + "Could not resolve Telegram routing for session_id=%r window=%r", + args.session_id, + args.window, + ) + sys.exit(1) + + chat_id, thread_id = routing + try: + with httpx.Client(timeout=10.0) as client: + resp = client.post( + f"https://api.telegram.org/bot{bot_token}/sendMessage", + data={ + "chat_id": str(chat_id), + "message_thread_id": str(thread_id), + "text": args.message, + }, + ) + if resp.status_code == 429: + logger.debug("Telegram rate limited ccbot send; skipping non-critical send") + sys.exit(0) + if not resp.is_success: + logger.error("Telegram API error %d: %s", resp.status_code, resp.text) + sys.exit(1) + except Exception as e: + logger.error("Failed to send message: %s", e) + sys.exit(1) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index a6e3c12b..8b1e2bc2 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -28,7 +28,7 @@ from dataclasses import dataclass, field from pathlib import Path from collections.abc import Iterator -from typing import Any +from typing import Any, Literal import aiofiles @@ -49,11 +49,14 @@ class WindowState: session_id: Associated Claude session ID (empty if not yet detected) cwd: Working directory for direct file path construction window_name: Display name of the window + provider: Runtime provider for the window. ``claude`` uses JSONL/session + hook tracking; ``codex`` uses tmux send/capture only. """ session_id: str = "" cwd: str = "" window_name: str = "" + provider: Literal["claude", "codex"] = "claude" def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -62,6 +65,11 @@ def to_dict(self) -> dict[str, Any]: } if self.window_name: d["window_name"] = self.window_name + # provider는 기본값('claude')일 때 직렬화 생략 — 기존 state.json + # 모든 row에 'provider': 'claude' 가 강제 주입되는 걸 막아 backward- + # compat 보존. from_dict 가 누락 시 'claude' 로 복원하므로 안전. + if self.provider != "claude": + d["provider"] = self.provider return d @classmethod @@ -70,6 +78,7 @@ 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", ""), + provider=data.get("provider", "claude"), ) @@ -442,9 +451,34 @@ def update_display_name(self, window_id: str, new_name: str) -> None: # Also update WindowState.window_name if it exists if window_id in self.window_states: self.window_states[window_id].window_name = new_name + self.window_states[window_id].provider = self.detect_window_provider( + new_name + ) self._save_state() logger.info("Updated display name: window_id %s -> '%s'", window_id, new_name) + @staticmethod + def detect_window_provider(window_name: str) -> Literal["claude", "codex"]: + """Infer provider from a tmux window display name. + + The first Codex integration is intentionally window-based. Users run + Codex/OMX direct in a named tmux window such as ``codex`` or + ``codex-api``; Claude remains the default for every other window. + """ + name = window_name.strip().lower() + return "codex" if name == "codex" or name.startswith("codex-") else "claude" + + def get_window_provider(self, window_id: str) -> Literal["claude", "codex"]: + """Return the provider for a tmux window, defaulting to Claude.""" + return self.get_window_state(window_id).provider + + def set_window_provider( + self, window_id: str, provider: Literal["claude", "codex"] + ) -> None: + """Persist the provider for a tmux window.""" + self.get_window_state(window_id).provider = provider + self._save_state() + # --- Group chat ID management (supergroup forum topic routing) --- def set_group_chat_id( @@ -562,6 +596,9 @@ async def load_session_map(self) -> None: if not new_sid: continue state = self.get_window_state(window_id) + if state.provider != "claude": + state.provider = "claude" + changed = True if state.session_id != new_sid or state.cwd != new_cwd: logger.info( "Session map: window_id %s updated sid=%s, cwd=%s", @@ -580,7 +617,18 @@ async def load_session_map(self) -> None: changed = True # Clean up window_states entries not in current session_map. - stale_wids = [w for w in self.window_states if w and w not in valid_wids] + stale_wids = [] + for w, state in self.window_states.items(): + if not w or w in valid_wids: + continue + display = state.window_name or self.window_display_names.get(w, "") + provider = state.provider + if display: + provider = self.detect_window_provider(display) + state.provider = provider + state.window_name = display + if provider == "claude": + stale_wids.append(w) for wid in stale_wids: logger.info("Removing stale window_state: %s", wid) del self.window_states[wid] @@ -594,7 +642,11 @@ async def load_session_map(self) -> None: def get_window_state(self, window_id: str) -> WindowState: """Get or create window state.""" if window_id not in self.window_states: - self.window_states[window_id] = WindowState() + display = self.window_display_names.get(window_id, "") + self.window_states[window_id] = WindowState( + window_name=display, + provider=self.detect_window_provider(display) if display else "claude", + ) return self.window_states[window_id] def clear_window_session(self, window_id: str) -> None: @@ -766,8 +818,11 @@ def bind_thread( if user_id not in self.thread_bindings: self.thread_bindings[user_id] = {} self.thread_bindings[user_id][thread_id] = window_id + state = self.get_window_state(window_id) if window_name: self.window_display_names[window_id] = window_name + state.window_name = window_name + state.provider = self.detect_window_provider(window_name) self._save_state() display = window_name or self.get_display_name(window_id) logger.info( @@ -855,15 +910,25 @@ async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: if not window: return False, "Window not found (may have been closed)" - # Check if Claude is currently generating a response. - # Claude TUI ignores key input while working, causing commands to be silently dropped. - pane_text = await tmux_manager.capture_pane(window.window_id) - if pane_text: - status = parse_status_line(pane_text) - if status and "esc to interrupt" in status.lower(): - return False, "Claude가 응답 생성 중입니다. 완료 후 다시 시도해주세요." + if self.get_window_provider(window_id) == "claude": + # Check if Claude is currently generating a response. + # Claude TUI ignores key input while working, causing commands to be + # silently dropped. Codex windows are handled by raw tmux capture and + # currently skip this Claude-specific status parser. + pane_text = await tmux_manager.capture_pane(window.window_id) + if pane_text: + status = parse_status_line(pane_text) + if status and "esc to interrupt" in status.lower(): + return ( + False, + "Claude가 응답 생성 중입니다. 완료 후 다시 시도해주세요.", + ) - success = await tmux_manager.send_keys(window.window_id, text) + success = await tmux_manager.send_keys( + window.window_id, + text, + use_paste=self.get_window_provider(window_id) == "codex", + ) if success: return True, f"Sent to {display}" return False, "Failed to send keys" diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py index 773a0d38..885d8b21 100644 --- a/src/ccbot/skill_registry.py +++ b/src/ccbot/skill_registry.py @@ -130,7 +130,7 @@ def scan(self) -> list[SkillInfo]: # e.g. "octo-km" → "km", "octo" stays "octo" prefix = plugin_name + "-" if file_stem.startswith(prefix): - cmd_name = file_stem[len(prefix):] + cmd_name = file_stem[len(prefix) :] elif file_stem == plugin_name: cmd_name = plugin_name else: diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index 1afefed0..97994773 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -78,13 +78,17 @@ class UIPattern: re.compile(r"^\s*Do you want to make this edit"), re.compile(r"^\s*Do you want to create \S"), re.compile(r"^\s*Do you want to delete \S"), + re.compile(r"^\s*Would you like to run the following command\?"), + ), + bottom=( + re.compile(r"^\s*Esc to cancel"), + re.compile(r"(?i)esc to cancel"), ), - bottom=(re.compile(r"^\s*Esc to cancel"),), ), UIPattern( # Permission menu with numbered choices (no "Esc to cancel" line) name="PermissionPrompt", - top=(re.compile(r"^\s*❯\s*1\.\s*Yes"),), + top=(re.compile(r"^\s*[❯›]\s*1\.\s*Yes"),), bottom=(), min_gap=2, ), @@ -198,6 +202,64 @@ def is_interactive_ui(pane_text: str) -> bool: # Spinner characters Claude Code uses in its status line STATUS_SPINNERS = frozenset(["·", "✻", "✽", "✶", "✳", "✢"]) +# ── codex status line patterns ───────────────────────────────────────── +# +# Claude status uses a spinner line near the bottom chrome. Codex TUI exposes +# progress as text lines such as `• Working (3s • esc to interrupt)` and tool +# lines such as `• Ran ...`. We only surface these status/tool lines; regular +# response text must not become a status update. + +CODEX_THINKING_RE = re.compile(r"^\s*•\s+Working\s+\(\d+s\b") +CODEX_TOOL_VERBS = ( + "Ran", + "Read", + "Edit", + "Wrote", + "Explored", + "Searched", + "Bash", + "Code", + "Patch", + "Diff", +) +CODEX_TOOL_RE = re.compile(rf"^\s*•\s+(?:{'|'.join(CODEX_TOOL_VERBS)})\b") +CODEX_HOOK_RE = re.compile( + r"^\s*•\s+(?:SessionStart|UserPromptSubmit|PreToolUse|PostToolUse|Stop)\s+hook\b" +) +CODEX_STATUS_BAR_RE = re.compile(r"^\s*gpt-[\d.]+(?:\s+\w+)?\s+·") +CODEX_TOOL_LINE_MAX = 100 + + +def parse_codex_status_line(pane_text: str) -> str | None: + """Extract Codex thinking/tool status from a captured pane. + + Returns a short status for in-place Telegram status updates, or None for + idle/regular response text. Final Codex replies are expected to be pushed by + the external OMX/codex hook bridge, not by pane snapshots. + """ + if not pane_text: + return None + + lines = pane_text.split("\n") + last_tool: str | None = None + + for line in reversed(lines): + stripped = line.strip() + if not stripped: + continue + if CODEX_STATUS_BAR_RE.match(line): + continue + if CODEX_HOOK_RE.match(line): + continue + if CODEX_THINKING_RE.match(line): + return f"⏳ {stripped[:CODEX_TOOL_LINE_MAX]}" + if last_tool is None and CODEX_TOOL_RE.match(line): + last_tool = stripped[:CODEX_TOOL_LINE_MAX] + + if last_tool is not None: + return f"🔧 {last_tool}" + return None + def parse_status_line(pane_text: str) -> str | None: """Extract the Claude Code status line from terminal output. @@ -263,6 +325,46 @@ def strip_pane_chrome(lines: list[str]) -> list[str]: return lines +ANSI_CONTROL_RE = re.compile( + r"(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-_])" +) + + +def strip_ansi_control_sequences(text: str) -> str: + """Remove ANSI escape/control sequences from captured terminal text.""" + text = ANSI_CONTROL_RE.sub("", text) + text = text.replace("\r\n", "\n").replace("\r", "\n") + # Keep normal newlines/tabs; remove other C0 controls that Telegram may show. + return "".join(ch for ch in text if ch == "\n" or ch == "\t" or ord(ch) >= 32) + + +def format_pane_snapshot(pane_text: str) -> str: + """Return a Telegram-friendly snapshot from captured tmux pane text. + + The first Codex bridge intentionally uses terminal capture instead of Codex + rollout JSONL. This helper keeps that output readable by stripping ANSI + sequences, removing known bottom chrome, and collapsing excessive blank + lines without truncating content at the parser layer. + """ + cleaned = strip_ansi_control_sequences(pane_text) + lines = strip_pane_chrome(cleaned.splitlines()) + + compact: list[str] = [] + blank_seen = False + for line in lines: + if line.strip(): + compact.append(line.rstrip()) + blank_seen = False + elif not blank_seen and compact: + compact.append("") + blank_seen = True + + while compact and not compact[-1].strip(): + compact.pop() + + return "\n".join(compact).strip() + + def extract_bash_output(pane_text: str, command: str) -> str | None: """Extract ``!`` command output from a captured tmux pane. diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index f05b4f3a..9f407b09 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -15,6 +15,8 @@ import asyncio import logging +import secrets +import subprocess from dataclasses import dataclass from pathlib import Path @@ -223,8 +225,80 @@ def _sync_capture() -> str | None: return await asyncio.to_thread(_sync_capture) + async def _send_via_paste(self, window_id: str, text: str) -> bool: + """Deliver text through tmux paste-buffer, then fire Enter. + + Codex's composer handles bracketed paste more reliably than direct + send-keys for full Telegram messages. The trailing Enter submits after + the paste has been processed. + """ + + def _do_paste() -> bool: + session = self.get_session() + if not session: + logger.error("No tmux session found") + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + logger.error(f"Window {window_id} not found") + return False + pane = window.active_pane + if not pane: + logger.error(f"No active pane in window {window_id}") + return False + target = pane.pane_id + if not target: + logger.error(f"No active pane ID in window {window_id}") + return False + buf_name = f"ccbot-{secrets.token_hex(4)}" + subprocess.run( + ["tmux", "set-buffer", "-b", buf_name, "--", text], + check=True, + timeout=5, + ) + subprocess.run( + ["tmux", "paste-buffer", "-b", buf_name, "-t", target, "-d"], + check=True, + timeout=5, + ) + return True + except subprocess.CalledProcessError as e: + logger.error(f"tmux paste failed for {window_id}: {e}") + return False + except Exception as e: + logger.error(f"Failed to paste to window {window_id}: {e}") + return False + + def _send_enter() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + pane = window.active_pane + if not pane: + return False + pane.send_keys("", enter=True, literal=False) + return True + except Exception as e: + logger.error(f"Failed to fire Enter on {window_id}: {e}") + return False + + if not await asyncio.to_thread(_do_paste): + return False + await asyncio.sleep(0.5) + return await asyncio.to_thread(_send_enter) + async def send_keys( - self, window_id: str, text: str, enter: bool = True, literal: bool = True + self, + window_id: str, + text: str, + enter: bool = True, + literal: bool = True, + use_paste: bool = False, ) -> bool: """Send keys to a specific window. @@ -234,10 +308,14 @@ async def send_keys( enter: Whether to press enter after the text literal: If True, send text literally. If False, interpret special keys like "Up", "Down", "Left", "Right", "Escape", "Enter". + use_paste: When True, route literal text through tmux paste-buffer. Returns: True if successful, False otherwise """ + if literal and enter and use_paste: + return await self._send_via_paste(window_id, text) + if literal and enter: # Split into text + delay + Enter via libtmux. # Claude Code's TUI sometimes interprets a rapid-fire Enter diff --git a/tests/ccbot/fixtures/codex_thinking_trace.txt b/tests/ccbot/fixtures/codex_thinking_trace.txt new file mode 100644 index 00000000..003e4e75 --- /dev/null +++ b/tests/ccbot/fixtures/codex_thinking_trace.txt @@ -0,0 +1,3237 @@ +=== t=01s 10:43:20 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + +=== t=02s 10:43:21 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + +=== t=03s 10:43:22 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (0s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + +=== t=04s 10:43:23 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (1s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=05s 10:43:24 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (2s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=06s 10:43:25 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (3s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=07s 10:43:26 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (4s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=08s 10:43:27 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (5s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=09s 10:43:28 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (6s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=10s 10:43:29 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (7s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=11s 10:43:30 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (8s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=12s 10:43:31 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (9s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=13s 10:43:32 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (10s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=14s 10:43:33 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (11s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=15s 10:43:35 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (12s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=16s 10:43:36 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (14s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=17s 10:43:37 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Working (15s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + + + + +=== t=18s 10:43:38 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Working (16s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + +=== t=19s 10:43:39 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Working (17s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + + + + +=== t=20s 10:43:40 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +• Working (18s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + + + +=== t=21s 10:43:41 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• Working (19s • esc to interrupt) + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + + + + + + + +=== t=22s 10:43:42 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=23s 10:43:43 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=24s 10:43:44 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=25s 10:43:45 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=26s 10:43:46 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=27s 10:43:47 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=28s 10:43:48 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=29s 10:43:49 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + +=== t=30s 10:43:50 === + +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.128.0 -> 0.129.0 │ +│ Run npm install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.128.0) │ +│ │ +│ model: gpt-5.5 high /model to change │ +│ directory: ~/Documents/Claude │ +╰────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage. + +⚠ Under-development features enabled: child_agents_md, goals. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` + in /Users/pakjungeol/.codex/config.toml. + + +› 안녕 + + +• SessionStart hook (completed) + hook context: [Execution environment]- surface: attached tmux runtime - tmux- omx runtime surfaces: omx team, omx hud, and omx question are directly usable in this session- omx question: visible temporary +renderer available from the current pane; primary success JSON is answers[] + +• Explored + └ Read SKILL.md + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 안녕하세요! 무엇을 도와드릴까요? + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 테스트로 안녕 + + +• 안녕! 테스트 정상입니다. + + +› 마지막 테스트로 안녕~ 지금 ccbot에서 메시지 작동하는거 테스트 중이야 + + +• 안녕~ 👋 + ccbot 메시지 송수신 테스트 정상으로 보입니다. + + +› 10초간 고민하다가 다시 안녕이라고 이야기해줘 + + +• Ran sleep 10 + └ (no output) + +• UserPromptSubmit hook (completed) + hook context: OMX native UserPromptSubmit triage detected a read-only/question-shaped request with no workflow keyword. This is advisory prompt-routing context only. Prefer the explore role surface rather +than escalating to autopilot. + + +› 머지 다시 왜 모시지가 안보내지지? + + +• 안녕~ + + 방금 요청대로 10초 대기 중이라 그동안 메시지가 안 보였을 가능성이 커요. + ccbot 쪽에서 타임아웃/중복 억제/스트리밍 종료 처리 때문에 지연 응답이 누락됐을 수도 있습니다. + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› 5초 기다린 후 README 첫 5줄을 보여줘 + + +• Ran sleep 5 && sed -n '1,5p' README.md + └ sed: README.md: No such file or directory + +• Explored + └ Search readme* in . + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +• 5초 대기 후 확인했지만 현재 디렉터리(/Users/pakjungeol/Documents/Claude)에 README.md 또는 README* 파일이 없습니다. + + 증거: + + sed: README.md: No such file or directory + +────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + +› Implement {feature} + + gpt-5.5 high · 5h 98% · weekly 73% · Context 93% left · main + + + + + + + + + + + + diff --git a/tests/ccbot/test_bot_codex.py b/tests/ccbot/test_bot_codex.py new file mode 100644 index 00000000..846dbc85 --- /dev/null +++ b/tests/ccbot/test_bot_codex.py @@ -0,0 +1,87 @@ +"""Tests for Codex window Telegram message handling.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _make_update( + text: str = "hello", user_id: int = 1, thread_id: int = 42 +) -> MagicMock: + update = MagicMock() + update.effective_user = MagicMock(id=user_id) + update.effective_chat = MagicMock(type="supergroup", 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_context() -> MagicMock: + context = MagicMock() + context.bot = AsyncMock() + context.user_data = {} + return context + + +@pytest.mark.asyncio +async def test_text_handler_forwards_codex_without_snapshot_capture(): + update = _make_update("run this") + 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.enqueue_status_update", new_callable=AsyncMock), + patch("ccbot.bot.enqueue_direct_message", new_callable=AsyncMock) as enqueue, + ): + mock_sm.get_window_for_thread.return_value = "@9" + mock_sm.get_window_provider.return_value = "codex" + mock_sm.resolve_chat_id.return_value = -100 + mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@9")) + mock_tmux.capture_pane = AsyncMock(return_value="") + mock_sm.send_to_window = AsyncMock(return_value=(True, "ok")) + + from ccbot.bot import text_handler + + await text_handler(update, context) + + mock_sm.send_to_window.assert_awaited_once_with("@9", "run this") + enqueue.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_codex_interactive_enter_only_sends_key(): + from ccbot.bot import callback_handler + from ccbot.handlers.callback_data import CB_ASK_ENTER + + update = MagicMock() + update.effective_user = MagicMock(id=1) + update.effective_chat = MagicMock(type="supergroup", id=-100) + update.message = None + update.callback_query = MagicMock() + update.callback_query.data = f"{CB_ASK_ENTER}@9" + update.callback_query.message = MagicMock(message_thread_id=42) + update.callback_query.answer = AsyncMock() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + 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), + patch("ccbot.bot.asyncio.sleep", new_callable=AsyncMock), + ): + mock_sm.get_window_provider.return_value = "codex" + mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@9")) + mock_tmux.send_keys = AsyncMock(return_value=True) + + await callback_handler(update, context) + + mock_tmux.send_keys.assert_awaited_once_with( + "@9", "Enter", enter=False, literal=False + ) diff --git a/tests/ccbot/test_send.py b/tests/ccbot/test_send.py new file mode 100644 index 00000000..7d9deb41 --- /dev/null +++ b/tests/ccbot/test_send.py @@ -0,0 +1,120 @@ +"""Tests for the ccbot send subcommand routing helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ccbot.send import _resolve_routing + + +def _write_state(tmp_path: Path, payload: dict) -> Path: + p = tmp_path / "state.json" + p.write_text(json.dumps(payload)) + return p + + +class TestResolveRouting: + def test_state_file_missing_returns_none(self, tmp_path: Path) -> None: + assert _resolve_routing(tmp_path / "nope.json", "", "x") is None + + def test_resolve_routing_by_window_name(self, tmp_path: Path) -> None: + """codex 브랜치 case — provider 가 박힌 window_state 직접 매칭.""" + p = _write_state( + tmp_path, + { + "window_states": {"@9": {"window_name": "codex", "provider": "codex"}}, + "thread_bindings": {"12345": {"42": "@9"}}, + "group_chat_ids": {"12345:42": -100999}, + }, + ) + assert _resolve_routing(p, "", "codex") == (-100999, 42) + + def test_window_states_match_by_session_id(self, tmp_path: Path) -> None: + p = _write_state( + tmp_path, + { + "window_states": { + "@9": {"session_id": "abc", "cwd": "/x", "window_name": "claude"} + }, + "thread_bindings": {"100": {"42": "@9"}}, + "group_chat_ids": {"100:42": -1234}, + }, + ) + assert _resolve_routing(p, "abc", "") == (-1234, 42) + + def test_fallback_to_display_names_when_window_states_empty( + self, tmp_path: Path + ) -> None: + """codex provider 등 startup-cleanup 후 첫 메시지 케이스. + + window_states 가 비어있어도 window_display_names + thread_bindings 만으로 + 라우팅 가능해야 omx hook 의 `ccbot send --window codex` 가 silent fail 안 함. + """ + p = _write_state( + tmp_path, + { + "window_states": {}, + "window_display_names": {"@27": "codex"}, + "thread_bindings": {"285987728": {"21357": "@27"}}, + "group_chat_ids": {"285987728:21357": -1003775904155}, + }, + ) + assert _resolve_routing(p, "", "codex") == (-1003775904155, 21357) + + def test_fallback_skips_stale_window_id_not_in_thread_bindings( + self, tmp_path: Path + ) -> None: + """ccbot 재기동 후 옛 window_id 가 display_names 에 잔존해도, 그 wid 가 + thread_bindings 에 실제 매핑돼 있지 않으면 fallback 후보에서 제외 — 새 + window_id 를 잡아야 silent fail 없음 (claude 브랜치 흡수).""" + p = _write_state( + tmp_path, + { + "window_states": {}, + "window_display_names": { + "@27": "codex", # stale (kickstart 전 cut) + "@6": "codex", # 진짜 활성 + }, + "thread_bindings": {"285987728": {"21357": "@6"}}, + "group_chat_ids": {"285987728:21357": -1003775904155}, + }, + ) + assert _resolve_routing(p, "", "codex") == (-1003775904155, 21357) + + def test_fallback_does_not_apply_for_session_id_only(self, tmp_path: Path) -> None: + """display_names 에는 session_id 정보가 없으므로 fallback 발동 안 함.""" + p = _write_state( + tmp_path, + { + "window_states": {}, + "window_display_names": {"@27": "codex"}, + "thread_bindings": {"100": {"42": "@27"}}, + "group_chat_ids": {"100:42": -1234}, + }, + ) + assert _resolve_routing(p, "some-session-id", "") is None + + def test_no_match_anywhere_returns_none(self, tmp_path: Path) -> None: + p = _write_state( + tmp_path, + { + "window_states": {}, + "window_display_names": {"@9": "claude"}, + "thread_bindings": {"100": {"42": "@9"}}, + "group_chat_ids": {"100:42": -1234}, + }, + ) + assert _resolve_routing(p, "", "ghost-window") is None + + def test_no_thread_binding_returns_none(self, tmp_path: Path) -> None: + p = _write_state( + tmp_path, + { + "window_states": {}, + "window_display_names": {"@27": "codex"}, + "thread_bindings": {}, + "group_chat_ids": {}, + }, + ) + assert _resolve_routing(p, "", "codex") is None diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py index 022fb55a..74a15e67 100644 --- a/tests/ccbot/test_session.py +++ b/tests/ccbot/test_session.py @@ -155,3 +155,90 @@ def test_invalid_ids(self, mgr: SessionManager) -> None: assert mgr._is_window_id("@") is False assert mgr._is_window_id("") is False assert mgr._is_window_id("@abc") is False + + +class TestWindowProvider: + def test_window_state_round_trips_codex_provider(self) -> None: + from ccbot.session import WindowState + + state = WindowState( + session_id="codex-thread-01", + cwd="/tmp/project", + window_name="codex", + provider="codex", + ) + + restored = WindowState.from_dict(state.to_dict()) + + assert restored.provider == "codex" + assert restored.window_name == "codex" + assert restored.session_id == "codex-thread-01" + + def test_to_dict_omits_default_provider_for_backward_compat(self) -> None: + """default('claude')일 때 provider 키를 직렬화하지 않아 기존 + state.json 모든 row 가 무수정으로 호환된다 (claude 브랜치 흡수).""" + from ccbot.session import WindowState + + codex_ws = WindowState(provider="codex", window_name="codex", cwd="/x") + assert codex_ws.to_dict()["provider"] == "codex" + + claude_ws = WindowState(window_name="claude", cwd="/x") + assert "provider" not in claude_ws.to_dict() + + def test_from_dict_legacy_state_defaults_to_claude(self) -> None: + """provider 키 없는 기존 state.json 도 'claude' 로 복원 (claude 브랜치 흡수).""" + from ccbot.session import WindowState + + legacy = {"session_id": "abc", "cwd": "/x", "window_name": "claude"} + ws = WindowState.from_dict(legacy) + assert ws.provider == "claude" + + def test_bind_thread_detects_codex_provider_from_window_name( + self, mgr: SessionManager + ) -> None: + mgr.bind_thread(100, 1, "@9", window_name="codex") + + assert mgr.get_window_provider("@9") == "codex" + + @pytest.mark.asyncio + async def test_load_session_map_preserves_codex_window_without_claude_session_map( + self, mgr: SessionManager, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + session_map = tmp_path / "session_map.json" + session_map.write_text( + '{"ccbot:@1":{"session_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",' + '"cwd":"/tmp/claude","window_name":"claude"}}' + ) + monkeypatch.setattr("ccbot.session.config.session_map_file", session_map) + monkeypatch.setattr("ccbot.session.config.tmux_session_name", "ccbot") + + mgr.bind_thread(100, 1, "@2", window_name="codex") + mgr.get_window_state("@2").session_id = "019e0198-7c94-7390-8b9a-a36a62b14747" + mgr.get_window_state("@2").cwd = "/tmp/codex" + + await mgr.load_session_map() + + assert "@2" in mgr.window_states + assert mgr.get_window_provider("@2") == "codex" + + @pytest.mark.asyncio + async def test_load_session_map_preserves_codex_from_display_name_only( + self, mgr: SessionManager, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + session_map = tmp_path / "session_map.json" + session_map.write_text( + '{"ccbot:@1":{"session_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",' + '"cwd":"/tmp/claude","window_name":"claude"}}' + ) + monkeypatch.setattr("ccbot.session.config.session_map_file", session_map) + monkeypatch.setattr("ccbot.session.config.tmux_session_name", "ccbot") + + mgr.thread_bindings[100] = {1: "@2"} + mgr.window_display_names["@2"] = "codex" + mgr.get_window_state("@2") + + await mgr.load_session_map() + + assert "@2" in mgr.window_states + assert mgr.get_window_state("@2").window_name == "codex" + assert mgr.get_window_provider("@2") == "codex" diff --git a/tests/ccbot/test_skill_registry.py b/tests/ccbot/test_skill_registry.py index 0d4d8bd1..caa6b4fc 100644 --- a/tests/ccbot/test_skill_registry.py +++ b/tests/ccbot/test_skill_registry.py @@ -123,7 +123,10 @@ def test_slash_command_preserves_original(self, tmp_path: Path) -> None: reg = SkillRegistry(plugins_dir, tmp_path / "state.json") reg.scan() - assert reg.get_slash_command("superpowers_systematic_debugging") == "/superpowers:systematic-debugging" + assert ( + reg.get_slash_command("superpowers_systematic_debugging") + == "/superpowers:systematic-debugging" + ) class TestNameCollision: diff --git a/tests/ccbot/test_status_polling_codex.py b/tests/ccbot/test_status_polling_codex.py new file mode 100644 index 00000000..10a6ebf6 --- /dev/null +++ b/tests/ccbot/test_status_polling_codex.py @@ -0,0 +1,157 @@ +"""Integration test for codex provider routing in status_polling.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ccbot.handlers.status_polling import update_status_message +from ccbot.session import SessionManager, WindowState + + +@pytest.fixture +def mgr(monkeypatch) -> SessionManager: + monkeypatch.setattr(SessionManager, "_load_state", lambda self: None) + monkeypatch.setattr(SessionManager, "_save_state", lambda self: None) + return SessionManager() + + +@pytest.mark.asyncio +async def test_codex_window_routes_to_codex_parser( + mgr: SessionManager, monkeypatch +) -> None: + """codex provider window 는 parse_codex_status_line 으로 분기되고 + 그 결과가 enqueue_status_update 에 전달된다.""" + ws = WindowState(provider="codex", cwd="/x", window_name="codex") + mgr.window_states["@27"] = ws + mgr.window_display_names["@27"] = "codex" + monkeypatch.setattr("ccbot.handlers.status_polling.session_manager", mgr) + + fake_window = MagicMock(window_id="@27") + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.find_window_by_id", + AsyncMock(return_value=fake_window), + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.capture_pane", + AsyncMock( + return_value=( + "› hi\n• Working (3s • esc to interrupt)\n gpt-5.5 high · main\n" + ) + ), + ) + + claude_parser = MagicMock(return_value="should-not-be-called") + codex_parser = MagicMock(return_value="⏳ Working (3s • esc to interrupt)") + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_status_line", claude_parser + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_codex_status_line", codex_parser + ) + + enqueue = AsyncMock() + monkeypatch.setattr("ccbot.handlers.status_polling.enqueue_status_update", enqueue) + monkeypatch.setattr( + "ccbot.handlers.status_polling.is_interactive_ui", lambda _t: False + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.get_interactive_window", lambda _u, _t: None + ) + + bot = MagicMock() + await update_status_message(bot, user_id=1, window_id="@27", thread_id=42) + + codex_parser.assert_called_once() + claude_parser.assert_not_called() + enqueue.assert_awaited_once() + args = enqueue.await_args.args + assert args[3] == "⏳ Working (3s • esc to interrupt)" + + +@pytest.mark.asyncio +async def test_claude_window_routes_to_claude_parser( + mgr: SessionManager, monkeypatch +) -> None: + """기본 provider(claude)는 기존 parse_status_line 흐름 유지 — 회귀 보호.""" + ws = WindowState(provider="claude", cwd="/x", window_name="claude") + mgr.window_states["@5"] = ws + mgr.window_display_names["@5"] = "claude" + monkeypatch.setattr("ccbot.handlers.status_polling.session_manager", mgr) + + fake_window = MagicMock(window_id="@5") + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.find_window_by_id", + AsyncMock(return_value=fake_window), + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.capture_pane", + AsyncMock(return_value="✻ Sautéed for 5s · 1 shell still running\n"), + ) + + claude_parser = MagicMock(return_value="✻ Sautéed for 5s") + codex_parser = MagicMock(return_value="should-not-be-called") + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_status_line", claude_parser + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_codex_status_line", codex_parser + ) + + enqueue = AsyncMock() + monkeypatch.setattr("ccbot.handlers.status_polling.enqueue_status_update", enqueue) + monkeypatch.setattr( + "ccbot.handlers.status_polling.is_interactive_ui", lambda _t: False + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.get_interactive_window", lambda _u, _t: None + ) + + bot = MagicMock() + await update_status_message(bot, user_id=1, window_id="@5", thread_id=42) + + claude_parser.assert_called_once() + codex_parser.assert_not_called() + + +@pytest.mark.asyncio +async def test_codex_fallback_via_display_name( + mgr: SessionManager, monkeypatch +) -> None: + """window_states 가 비어 있어도 display_name == 'codex' 면 codex 분기.""" + # WindowState 의도적으로 등록 X — startup cleanup 직후 시나리오 + mgr.window_display_names["@99"] = "codex" + monkeypatch.setattr("ccbot.handlers.status_polling.session_manager", mgr) + + fake_window = MagicMock(window_id="@99") + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.find_window_by_id", + AsyncMock(return_value=fake_window), + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.tmux_manager.capture_pane", + AsyncMock(return_value="• Working (1s • esc to interrupt)\n"), + ) + + claude_parser = MagicMock(return_value=None) + codex_parser = MagicMock(return_value="⏳ Working (1s)") + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_status_line", claude_parser + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.parse_codex_status_line", codex_parser + ) + + enqueue = AsyncMock() + monkeypatch.setattr("ccbot.handlers.status_polling.enqueue_status_update", enqueue) + monkeypatch.setattr( + "ccbot.handlers.status_polling.is_interactive_ui", lambda _t: False + ) + monkeypatch.setattr( + "ccbot.handlers.status_polling.get_interactive_window", lambda _u, _t: None + ) + + bot = MagicMock() + await update_status_message(bot, user_id=1, window_id="@99", thread_id=42) + + codex_parser.assert_called_once() + claude_parser.assert_not_called() diff --git a/tests/ccbot/test_terminal_parser.py b/tests/ccbot/test_terminal_parser.py index 08118430..5ac2b6a9 100644 --- a/tests/ccbot/test_terminal_parser.py +++ b/tests/ccbot/test_terminal_parser.py @@ -101,6 +101,27 @@ def test_permission_prompt(self, sample_pane_permission: str): assert result.name == "PermissionPrompt" assert "Do you want to proceed?" in result.content + def test_codex_command_permission_prompt(self): + pane = ( + " Would you like to run the following command?\n" + "\n" + " Reason: Telegram 메시지 수신 여부를 확인합니다.\n" + "\n" + " $ printf '%s\\n' '--- process ---'\n" + "\n" + "› 1. Yes, proceed (y)\n" + " 2. No, and tell Codex what to do differently (esc)\n" + "\n" + " Press enter to confirm or esc to cancel\n" + ) + + result = extract_interactive_content(pane) + + assert result is not None + assert result.name == "PermissionPrompt" + assert "Would you like to run the following command?" in result.content + assert "Press enter to confirm or esc to cancel" in result.content + def test_restore_checkpoint(self): pane = ( " Restore the code to a previous state?\n" @@ -263,3 +284,94 @@ def test_trailing_blank_lines_stripped(self): result = extract_bash_output(pane, "echo hi") assert result is not None assert not result.endswith("\n") + + +class TestPaneSnapshotFormatting: + def test_strip_ansi_control_sequences(self): + from ccbot.terminal_parser import strip_ansi_control_sequences + + assert strip_ansi_control_sequences("\x1b[31mred\x1b[0m\r\n") == "red\n" + + def test_format_pane_snapshot_strips_chrome_and_limits_blank_lines(self): + from ccbot.terminal_parser import format_pane_snapshot + + pane = ( + "\x1b[32mAnswer line\x1b[0m\n" + "\n" + "\n" + "More detail\n" + "──────────────────────────────────────\n" + "❯ prompt\n" + "──────────────────────────────────────\n" + "model · context\n" + ) + + assert format_pane_snapshot(pane) == "Answer line\n\nMore detail" + + +class TestParseCodexStatusLine: + """Codex provider thinking/tool status extraction.""" + + def test_working_line_returns_thinking_status(self) -> None: + from ccbot.terminal_parser import parse_codex_status_line + + pane = ( + "› 5초 기다린 후 README 출력\n" + "• Working (3s • esc to interrupt)\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + + result = parse_codex_status_line(pane) + + assert result is not None + assert result.startswith("⏳") + assert "Working" in result + assert "(3s" in result + + def test_tool_use_line_returns_tool_status(self) -> None: + from ccbot.terminal_parser import parse_codex_status_line + + pane = ( + "› LICENSE 보여줘\n" + "• Read LICENSE\n" + " gpt-5.5 high · 5h 99% · weekly 73% · Context 94% left · main\n" + ) + + result = parse_codex_status_line(pane) + + assert result is not None + assert result.startswith("🔧") + assert "Read" in result + + def test_response_text_returns_none(self) -> None: + from ccbot.terminal_parser import parse_codex_status_line + + pane = "› 안녕\n• 안녕하세요! 무엇을 도와드릴까요?\n gpt-5.5 high · main\n" + + assert parse_codex_status_line(pane) is None + + def test_hook_meta_lines_filtered(self) -> None: + from ccbot.terminal_parser import parse_codex_status_line + + pane = ( + "› 안녕\n" + "• SessionStart hook (completed)\n" + "• UserPromptSubmit hook (completed)\n" + "• Working (1s • esc to interrupt)\n" + " gpt-5.5 high · main\n" + ) + + result = parse_codex_status_line(pane) + + assert result is not None + assert "Working" in result + + def test_idle_returns_none(self) -> None: + from ccbot.terminal_parser import parse_codex_status_line + + assert parse_codex_status_line("") is None + assert parse_codex_status_line("\n\n\n") is None + assert ( + parse_codex_status_line("› Implement {feature}\n gpt-5.5 high · main\n") + is None + ) From 637f4ca5bb09755ce48231bfe3c7a9f017fc2ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=EA=B1=B8?= Date: Fri, 8 May 2026 18:20:56 +0900 Subject: [PATCH 32/35] =?UTF-8?q?fix(queue):=20keep=20answer=20as=20last?= =?UTF-8?q?=20message=20=E2=80=94=20drop=20immediate=20status=20enqueue=20?= =?UTF-8?q?(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(queue): drop immediate status enqueue after content tasks Status was being re-sent immediately after every content message via _check_and_send_status, pushing the latest answer above the status indicator. The user could not see their final answer because '⚙️ working' kept appearing below it. Delegate status display entirely to status_polling (1s interval). The answer now stays as the last visible message; status only appears in gaps longer than ~1s and is converted into the next content message via _convert_status_to_content. Removed 3 immediate _check_and_send_status calls (tool_result edit path, fallback edit path, normal content send path). Co-Authored-By: Claude Opus 4.7 (1M context) * fix: persist status message IDs and clean up orphans on restart Status messages were tracked in-memory only. On each ccbot restart, old status messages (Metamorphosing…, Baked for Ns, etc.) were orphaned in Telegram while new ones were created, causing them to accumulate with each restart. Fix: - session.py: add status_msg_ids field, persist to state.json - message_queue.py: call session_manager on send/delete - bot.py post_init: delete persisted status messages on startup Also adds ccbot send subcommand dispatch to main.py (from this session). Also: add CCBOT_SHOW_TOOL_CALLS=false to ~/.ccbot/.env so tool_use/ tool_result are filtered before the batcher, reducing batch summary noise. Co-Authored-By: Claude Sonnet 4.6 * fix: clear persisted status msg ID when converting status to content _convert_status_to_content edits the Telegram message in-place to show Claude's response. The message ID is no longer a status message, but session_manager.status_msg_ids still held the old ID — causing ccbot to delete the content message on next restart. Clear the persisted ID immediately after popping from _status_msg_info, before any edit/delete attempt. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.7 (1M context) From a3f9500c6bbff88aa3964e801f7a613b751f2171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=EA=B1=B8?= Date: Fri, 8 May 2026 18:47:53 +0900 Subject: [PATCH 33/35] fix: ignore background-shell-only spinner in parse_status_line (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a turn ends but a backgrounded Bash tool is still alive, Claude Code briefly shows a spinner line like '· Sautéed for 3s · 1 shell still running' above the chrome separator. status_polling caught this as a working status and enqueued a new status message — but since the line lacks 'esc to interrupt', no further updates arrive and the message is left stale on Telegram after the background shell exits. Filter spinner lines that match the background-shell indicator pattern (/\d+ shells? still running/) and contain no 'esc to interrupt' signal — treat them as turn-complete, not as active working state. Tests cover both the filtering (1 shell / 2 shells / Generating variant) and the regression guard for genuine active working spinners. Co-authored-by: Claude Opus 4.7 --- src/ccbot/terminal_parser.py | 17 +++++++++++- tests/ccbot/test_terminal_parser.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index 97994773..e8d542b5 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -261,6 +261,9 @@ def parse_codex_status_line(pane_text: str) -> str | None: return None +_BACKGROUND_SHELL_RE = re.compile(r"\b\d+\s+shells?\s+still\s+running\b") + + def parse_status_line(pane_text: str) -> str | None: """Extract the Claude Code status line from terminal output. @@ -270,6 +273,12 @@ def parse_status_line(pane_text: str) -> str | None: false positives from ``·`` bullets in Claude's regular output. Returns the text after the spinner, or None if no status line found. + + Background-only indicator filter: when the spinner text only reflects a + surviving backgrounded Bash tool (e.g. ``Sautéed for 3s · 1 shell still + running``) and contains no active working signal (``esc to interrupt``), + the turn is effectively over — return None so a new status message is not + enqueued and left stale once the background shell exits. """ if not pane_text: return None @@ -294,7 +303,13 @@ def parse_status_line(pane_text: str) -> str | None: if not line: continue if line[0] in STATUS_SPINNERS: - return line[1:].strip() + rest = line[1:].strip() + if ( + _BACKGROUND_SHELL_RE.search(rest) + and "esc to interrupt" not in rest.lower() + ): + return None + return rest # First non-empty line above separator isn't a spinner → no status return None return None diff --git a/tests/ccbot/test_terminal_parser.py b/tests/ccbot/test_terminal_parser.py index 5ac2b6a9..54e824ba 100644 --- a/tests/ccbot/test_terminal_parser.py +++ b/tests/ccbot/test_terminal_parser.py @@ -62,6 +62,49 @@ def test_false_positive_bullet(self, chrome: str): def test_uses_fixture(self, sample_pane_status_line: str): assert parse_status_line(sample_pane_status_line) == "Reading file src/main.py" + @pytest.mark.parametrize( + "spinner_text", + [ + "· Sautéed for 3s · 1 shell still running", + "· Sautéed for 12s · 2 shells still running", + "✻ Generating… (3s · 1 shell still running)", + ], + ) + def test_background_shell_indicator_not_status( + self, spinner_text: str, chrome: str + ): + """Spinner line that only indicates background shells (no active working + signal like 'esc to interrupt') must not be treated as a working status. + + These lines appear briefly after a turn ends while a backgrounded Bash + tool is still alive — the user is free to send the next message, so we + must not enqueue a stale status message that would persist after the + background shell exits. + """ + pane = f"some output\n{spinner_text}\n{chrome}" + assert parse_status_line(pane) is None + + @pytest.mark.parametrize( + ("spinner_text", "expected"), + [ + ( + "· Sautéed for 3s · esc to interrupt", + "Sautéed for 3s · esc to interrupt", + ), + ( + "✻ Generating… (12s · ↓ 2k tokens · esc to interrupt)", + "Generating… (12s · ↓ 2k tokens · esc to interrupt)", + ), + ], + ) + def test_active_working_still_detected( + self, spinner_text: str, expected: str, chrome: str + ): + """Active working spinner ('esc to interrupt' present) must still be + detected — only background-only indicators are filtered out.""" + pane = f"some output\n{spinner_text}\n{chrome}" + assert parse_status_line(pane) == expected + # ── extract_interactive_content ────────────────────────────────────────── From f48fcd51caa9f2bfb10ba18126cc6e233b370149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A4=80=EA=B1=B8?= Date: Fri, 8 May 2026 19:30:14 +0900 Subject: [PATCH 34/35] =?UTF-8?q?docs(readme):=20=EB=B3=B8=EC=B2=B4=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=20fork=20=EC=B0=A8=EC=9D=B4=EC=A0=90=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=20+=20Changelog=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단에 fork notice 추가하여 TejNote/ccbot이 six-ddc/ccbot 의 fork임을 명확히 표시. Features 섹션을 'Upstream' / 'Fork additions' 두 그룹으로 분리해 어떤 기능이 fork 추가분인지 한눈에 보이도록 정리. Codex/OMX provider 라우팅, plugin skill menu (`/favorite`+사용 빈도 sorting), MessageBatcher, DirectMessage 큐, `ccbot send` CLI subcommand, status msg ID 영속화, parse_status_line의 background-shell spinner 차단 등 fork 추가 기능을 Fork additions 절에 항목별로 설명. 옵션 env 변수 표에도 🔱 마크로 fork-only 설정(`CCBOT_BATCH_WINDOW`, `CCBOT_SHOW_USER_MESSAGES`, `CCBOT_SHOW_TOOL_CALLS`)을 표시. File Structure 트리에 send.py / skill_registry.py / message_batcher.py 등 신규 파일과 수정된 모듈을 🔱 표시와 함께 명시. Changelog (fork) 섹션 신설 — 머지된 PR(#1, #2, #4, #5)과 주요 commit을 시간 역순으로 정리하고, 아직 fork에 반영 안 된 upstream 커밋 3건(#67, #73, f5ddd7f)을 'Pending upstream merges' 표로 트래킹. Co-authored-by: Claude Opus 4.7 --- README.md | 222 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 152 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index afc6f1b7..750a7a63 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ -# CCBot +# CCBot (TejNote fork) [中文文档](README_CN.md) [Русская документация](README_RU.md) -Control Claude Code sessions remotely via Telegram — monitor, interact, and manage AI coding sessions running in tmux. +> 🔱 **This is a fork** of [six-ddc/ccbot](https://github.com/six-ddc/ccbot) maintained at [TejNote/ccbot](https://github.com/TejNote/ccbot). +> Adds Codex/OMX provider routing, a plugin skill menu, message batching/ordering, and several reliability fixes for the Telegram ↔ tmux bridge. See [Fork additions](#fork-additions) and [Changelog (fork)](#changelog-fork) below for details. + +Control Claude Code (and Codex/OMX) sessions remotely via Telegram — monitor, interact, and manage AI coding sessions running in tmux. https://github.com/user-attachments/assets/15ffb38e-5eb9-4720-93b9-412e4961dc93 @@ -23,44 +26,53 @@ In fact, CCBot itself was built this way — iterating on itself through Claude ## Features +### Upstream (shared with [six-ddc/ccbot](https://github.com/six-ddc/ccbot)) + - **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 +- **Real-time notifications** — Assistant responses, thinking content, tool use/result, 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`) -- **Plugin skill menu** — Installed Claude Code plugin skills (superpowers, pr-review-toolkit, etc.) are auto-discovered and registered in the Telegram `/` command menu -- **Skill favorites** — Toggle favorites via `/favorite` to pin frequently-used skills to the top of the menu -- **Usage-based sorting** — Skills are sorted by per-project usage frequency, so your most-used skills surface first -- **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) +- **Create / resume / kill sessions** — Start fresh or pick up an existing Claude session via directory browser; close a topic to auto-kill the window +- **Message history** — Browse conversation history with pagination - **Hook-based session tracking** — Auto-associates tmux windows with Claude sessions via `SessionStart` hook - **Persistent state** — Thread bindings and read offsets survive restarts +### 🔱 Fork additions + +- **Codex / OMX provider routing** — `codex` / `codex-*` windows are auto-detected and routed bidirectionally. Uses tmux paste-buffer (vs. plain send-keys) so the Codex composer receives a single bracketed-paste event. A separate status parser (`parse_codex_status_line`) reports `⏳ Working` and `🔧 ` lines from Codex output. State serialization stays backward-compatible (default `provider=claude` is omitted). +- **Plugin skill menu with usage sorting** — Installed Claude Code plugin skills (superpowers, pr-review-toolkit, octo, etc.) are auto-discovered at startup and registered as Telegram `/` commands. Skills with Korean descriptions show localized text. Use `/favorite` to pin frequently-used skills; per-project usage frequency sorts the rest. +- **MessageBatcher** — Tool-use and thinking events are grouped into a periodic summary (`⚙️ 작업 중 N건`) instead of flooding the chat. Configurable via `CCBOT_BATCH_WINDOW`. +- **DirectMessage queue** — Confirmation messages (commands, photo/voice acks) are routed through the per-user message queue so they never interleave with assistant output. +- **`ccbot send` CLI subcommand** — `ccbot send --session-id ` and `ccbot send --window ` let external hooks (e.g. `Stop`, `PostToolUse`) push messages to a topic without going through Telegram. +- **Persistent status message IDs** — `state.json` now tracks live status message IDs and the bot deletes orphans on next startup, so a crash-and-restart no longer leaves dangling `⏳ Working` messages on the chat. +- **Status polling reliability fixes** — `parse_status_line` ignores background-shell-only spinners (`Sautéed for 3s · 1 shell still running`) so the answer remains the last visible message after a turn ends. Status update is delegated entirely to the polling loop (no immediate enqueue from the content task path). +- **Claude busy-state guard** — `send_keys` checks the receiving pane is idle before transmitting, preventing silent command drops. +- **Hook hardening** — Tmux session name normalization via `TMUX_SESSION_NAME`; `.env` value quoting stripped; `/clear` resets `session_map` correctly. + ## Prerequisites - **tmux** — must be installed and available in PATH - **Claude Code** — the CLI tool (`claude`) must be installed +- **Codex / OMX** *(optional)* — required only if you want Codex windows routed; install [`omx`](https://github.com/) and the bundled `~/Documents/Claude/.omx/hooks/ccbot-bridge.mjs` plugin ## Installation -### Option 1: Install from GitHub (Recommended) +### Option 1: Install from this fork (Recommended) ```bash # Using uv (recommended) -uv tool install git+https://github.com/six-ddc/ccmux.git +uv tool install git+https://github.com/TejNote/ccbot.git # Or using pipx -pipx install git+https://github.com/six-ddc/ccmux.git +pipx install git+https://github.com/TejNote/ccbot.git ``` ### Option 2: Install from source ```bash -git clone https://github.com/six-ddc/ccmux.git -cd ccmux +git clone https://github.com/TejNote/ccbot.git ccbot-src +cd ccbot-src uv sync ``` @@ -91,18 +103,20 @@ ALLOWED_USERS=your_telegram_user_id **Optional:** -| 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) | - -Message formatting is always HTML via `chatgpt-md-converter` (`chatgpt_md_converter` package). -There is no runtime formatter switch to MarkdownV2. +| 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) | +| `CCBOT_BATCH_WINDOW` | `10` | 🔱 Seconds before MessageBatcher emits a summary (`0` to disable batching) | +| `CCBOT_SHOW_USER_MESSAGES` | `true` | 🔱 Echo the user's own message back to the topic (set `false` to suppress) | +| `CCBOT_SHOW_TOOL_CALLS` | `true` | 🔱 Forward `tool_use` / `tool_result` events (set `false` to keep only summaries) | + +🔱 = fork-specific. > If running on a VPS where there's no interactive terminal to approve permissions, consider: > @@ -134,6 +148,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. +### `Stop` hook bridge (fork) + +Pair with the `ccbot send` subcommand to push per-window summaries from arbitrary hooks. Example: `~/.local/bin/claude-stop-notify.sh` runs on `Stop`, computes a `git diff --shortstat`, and calls `ccbot send --session-id "$SESSION_ID" "📊 [] N개 파일 변경, M줄 추가"`. + ## Usage ```bash @@ -142,22 +160,27 @@ ccbot # If installed from source uv run ccbot + +# Hook helper / inter-process messaging (fork) +ccbot hook --install +ccbot send --session-id "" +ccbot send --window "" ``` ### 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 | -| `/kill` | Kill session and delete topic | -| `/unbind` | Unbind topic from session | -| `/usage` | Show Claude Code usage remaining | -| `/favorite` | Toggle skill favorites | +| Command | Description | +| ------------- | --------------------------------- | +| `/start` | Show welcome message | +| `/history` | Message history for this topic | +| `/screenshot` | Capture terminal screenshot | +| `/esc` | Send Escape to interrupt Claude | +| `/kill` | Kill session and delete topic | +| `/unbind` | Unbind topic from session | +| `/usage` | Show Claude Code usage remaining | +| `/favorite` | 🔱 Toggle skill favorites | **Claude Code commands (forwarded via tmux):** @@ -172,20 +195,20 @@ uv run ccbot Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/review`, `/doctor`, `/init`). -**Plugin skills (auto-discovered):** +**Plugin skills (auto-discovered, fork):** -Installed Claude Code plugins are automatically scanned at startup. Their skills appear in the Telegram `/` command menu alongside built-in commands. Skills with Korean translations show localized descriptions. For example: +Installed Claude Code plugins are scanned at startup. Their skills appear in the Telegram `/` command menu alongside built-in commands. Skills with Korean translations show localized descriptions. For example: -| Command | Description | -| -------------------------- | ------------------------------ | +| Command | Description | +| -------------------------- | ------------------------------------ | | `/brainstorming` | ↗ 브레인스토밍 — 기능 설계 전 아이디어 구체화 | -| `/systematic_debugging` | ↗ 체계적 디버깅 | -| `/writing_plans` | ↗ 구현 계획 작성 | -| `/test_driven_development` | ↗ TDD — 테스트 주도 개발 | -| `/skill_debug` | ↗ Octo 디버깅 | -| ... | (all installed plugin skills) | +| `/systematic_debugging` | ↗ 체계적 디버깅 | +| `/writing_plans` | ↗ 구현 계획 작성 | +| `/test_driven_development` | ↗ TDD — 테스트 주도 개발 | +| `/skill_debug` | ↗ Octo 디버깅 | +| ... | (all installed plugin skills) | -Use `/favorite` to pin your most-used skills to the top of the menu. +Use `/favorite` to pin your most-used skills to the top of the menu. Per-project usage counts surface the rest by frequency. ### Topic Workflow @@ -203,6 +226,16 @@ Use `/favorite` to pin your most-used skills to the top of the menu. 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. +**Codex / OMX windows (fork):** + +Windows named `codex` or `codex-*` are routed to OMX in `direct` mode. Set this in your launcher (e.g. ccbot's bootstrap script): + +```bash +OMX_LAUNCH_POLICY=direct omx +``` + +Status updates from Codex (`Working`, `Ran`, `Read`, `Edit`, etc.) flow through the same Telegram pipeline as Claude. The `ccbot-bridge.mjs` OMX hook plugin (at `~/Documents/Claude/.omx/hooks/`) emits assistant responses back to the topic via `ccbot send`. + **Killing a session:** Close (or delete) the topic in Telegram. The associated tmux window is automatically killed and the binding is removed. @@ -231,7 +264,7 @@ The monitor polls session JSONL files every 2 seconds and sends notifications fo - **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") +- **Tool use/result** — Summarized with stats (e.g. "Read 42 lines", "Found 5 matches"); on this fork, repeated tool-use events within `CCBOT_BATCH_WINDOW` collapse into `⚙️ 작업 중 N건` - **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. @@ -261,13 +294,13 @@ The window must be in the `ccbot` tmux session (configurable via `TMUX_SESSION_N ## 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) | -| `$CCBOT_DIR/skill_state.json` | Skill favorites and per-project usage counts | -| `~/.claude/projects/` | Claude Code session data (read-only) | +| Path | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------- | +| `$CCBOT_DIR/state.json` | Thread bindings, window states (incl. `provider`), display names, read offsets, **status_msg_ids** | +| `$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) | +| `$CCBOT_DIR/skill_state.json` | 🔱 Skill favorites and per-project usage counts | +| `~/.claude/projects/` | Claude Code session data (read-only) | ## File Structure @@ -276,34 +309,83 @@ src/ccbot/ ├── __init__.py # Package entry point ├── main.py # CLI dispatcher (hook subcommand + bot bootstrap) ├── hook.py # Hook subcommand for session tracking (+ --install) +├── send.py # 🔱 ccbot send subcommand (--session-id / --window) ├── 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 +├── terminal_parser.py # Terminal pane parsing (interactive UI + status line + 🔱 Codex parser) +├── markdown_v2.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 -├── skill_registry.py # Plugin skill discovery and Telegram command registration -├── message_batcher.py # Batch tool_use/thinking messages into summaries +├── skill_registry.py # 🔱 Plugin skill discovery and Telegram command registration +├── message_batcher.py # 🔱 Batch tool_use/thinking messages into summaries ├── utils.py # Shared utilities (atomic JSON writes, JSONL helpers) -├── tmux_manager.py # Tmux window management (list, create, send keys, kill) +├── tmux_manager.py # Tmux window management (incl. 🔱 paste-buffer path for Codex) ├── telegram_sender.py # Telegram message splitting (4096 char limit) ├── 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 + ├── __init__.py + ├── callback_data.py + ├── cleanup.py + ├── directory_browser.py + ├── history.py + ├── interactive_ui.py + ├── message_queue.py # Per-user queue + worker (🔱 DirectMessage, status convert) + ├── message_sender.py + ├── response_builder.py + └── status_polling.py # Terminal status polling (1s interval) ``` +🔱 = file or section added/extended in this fork. + +## Changelog (fork) + +Tracked here so internal contributors can see what changed relative to upstream `six-ddc/ccbot`. Most recent first. + +### 2026-05-08 + +- **PR [#5](https://github.com/TejNote/ccbot/pull/5) `fix:` parse_status_line ignores background-shell-only spinners** — Lines like `· Sautéed for 3s · 1 shell still running` (no `esc to interrupt` signal) are no longer enqueued as status updates, eliminating stale status messages that lingered on Telegram after a turn ended. +- **PR [#4](https://github.com/TejNote/ccbot/pull/4) `feat:` Codex topic routing** — `WindowState.provider`, `detect_window_provider`, paste-buffer send path, `parse_codex_status_line`, OMX `ccbot-bridge.mjs` hook, fixtures (`codex_thinking_trace.txt`), and tests (`TestWindowProvider`, `TestResolveRouting`, `TestParseCodexStatusLine`, `test_status_polling_codex.py`). +- **PR [#2](https://github.com/TejNote/ccbot/pull/2) `fix:` keep answer as last message** — Status display delegated to `status_polling`; content tasks no longer immediately re-enqueue a status update. Status message IDs are persisted in `state.json` and orphans cleaned on restart. + +### 2026-05-06 + +- `fix:` `/clear` no longer leaves a stale `session_map` entry — the next message correctly maps to the new session. +- `feat:` skill scanning includes `commands/` directories (e.g. `/octo:octo`, all CLI slash commands) and budgets descriptions to stay under Telegram's ~5000-char total command limit. +- `fix:` total bot-command count capped at 100 (Telegram API limit) with sane truncation. + +### 2026-04-28 + +- `feat:` route command/photo/voice acknowledgements through `enqueue_direct_message` so they don't interleave with Claude responses. +- `feat:` `DirectMessage` task type added to the queue; `enqueue_direct_message` for ordered delivery. + +### 2026-04-27 + +- `feat:` `SkillRegistry` integrated into the bot (`/favorite`, usage tracking, command menu). +- `feat:` `MessageBatcher` for timed grouping of tool-use / thinking events; `CCBOT_BATCH_WINDOW` config. +- `fix:` hook strips quotes from `.env` values and normalizes `TMUX_SESSION_NAME`. +- `fix:` `send_keys` checks Claude busy state before transmission to prevent silent drops. +- PR [#1](https://github.com/TejNote/ccbot/pull/1) `fix:` batch summary correctly traverses the message queue. + +### Pending upstream merges + +These commits are on `six-ddc/ccbot` `main` but not yet reconciled into this fork: + +| Upstream commit | Note | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| [`865ab89`](https://github.com/six-ddc/ccbot/commit/865ab89) (#67) | Fix interactive UI creating duplicate messages on button press | +| [`350c653`](https://github.com/six-ddc/ccbot/commit/350c653) (#73) | Stop renaming user-created Telegram topics on bind | +| [`f5ddd7f`](https://github.com/six-ddc/ccbot/commit/f5ddd7f) | Show correct line count for Write tool results | + +Plan: cherry-pick or merge in a follow-up PR. + +## Contributing back upstream + +Bug fixes that aren't fork-specific (e.g. anything not touching Codex routing, the skill menu, or the `ccbot send` subcommand) are welcome upstream — open the PR against [`six-ddc/ccbot`](https://github.com/six-ddc/ccbot) directly. For fork-specific work, target this repository's `main`. + ## Contributors Thanks to all the people who contribute! We encourage using Claude Code to collaborate on contributions. From 061e00f05f602bf0779d3c8869f6aa193c53be74 Mon Sep 17 00:00:00 2001 From: Tej Date: Thu, 14 May 2026 11:38:01 +0900 Subject: [PATCH 35/35] =?UTF-8?q?chore(release):=20SemVer=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20v1.0.0=20=EC=B2=AB=20=EA=B3=B5=EC=8B=9D?= =?UTF-8?q?=20=EB=A6=B4=EB=A6=AC=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 fork는 pyproject.toml version=0.1.0으로 박제된 채 변경 이력만 README에 누적되어 있어 "지금 어떤 버전이고 뭐가 새로 들어갔는지" 추적이 어려웠음. SemVer + CHANGELOG.md 체계로 전환해 슬랙 공유 시 vX.Y.Z 변경분만 전달 가능하게 함. 변경 - pyproject.toml: 0.1.0 → 1.0.0 - CHANGELOG.md 신설 (Keep a Changelog 포맷) - v1.0.0 = fork 누적분 전체 (Codex routing, skill menu, MessageBatcher, DirectMessage queue, ccbot send CLI, status msg orphan cleanup, spinner 무시, busy-state guard 등) - Added/Changed/Fixed/Pending upstream merges 카테고리로 재구성 - README.md Changelog 섹션 39줄 → 3줄로 단순화 (CHANGELOG.md 링크만 유지) 정책 - MAJOR: 호환성 깨는 변경 (state.json 스키마, .env 키 rename, CLI 인자) - MINOR: 기능 추가 (provider, hook, 명령어) - PATCH: 버그픽스, 안정성, 문서 다음 릴리스부터는 [Unreleased] 섹션에 누적 → 릴리스 시 promote + tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 39 ++--------------------- pyproject.toml | 2 +- 3 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..15caae9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +이 fork(`TejNote/ccbot`)가 upstream(`six-ddc/ccbot`) 대비 어떻게 달라졌는지 추적합니다. + +포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/), 버전 정책은 [SemVer](https://semver.org/lang/ko/)를 따릅니다. + +- **MAJOR** (v2.0.0): 기존 사용자가 영향을 받는 호환성 깨는 변경 (state.json 스키마, `.env` 키 이름, CLI 인자 등) +- **MINOR** (v1.x.0): 기능 추가 — 새 hook, 새 명령어, 새 provider 지원 등 +- **PATCH** (v1.0.x): 버그 픽스, 안정성 개선, 문서 보정 + +## [Unreleased] + +(다음 릴리스 준비 중인 변경은 여기에 누적) + +--- + +## [1.0.0] - 2026-05-14 + +TejNote fork의 첫 공식 버전. 2026-04-27 이후 누적된 fork 전용 추가 사항을 한 번에 v1.0.0으로 정리합니다 (이전 내부 버전 `0.1.0`). + +### Added (새 기능) + +- **Codex / OMX provider 양방향 라우팅** ([#4](https://github.com/TejNote/ccbot/pull/4)) + - `codex` / `codex-*` tmux 창을 자동 감지해 텔레그램 토픽과 양방향 연결 + - Codex composer 전용 입력 경로: tmux `set-buffer` + `paste-buffer -d` + `Enter`로 single bracketed-paste 이벤트 전달 (직접 send-keys 시 newline 누적 문제 우회) + - 별도 status 파서 `parse_codex_status_line`: `⏳ Working`, `🔧 ` 라인 인식 + - state.json 하위 호환: 기본값 `provider=claude`는 직렬화 생략 + - OMX hook plugin (`ccbot-bridge.mjs`): `turn-complete` 이벤트 → `ccbot send`로 텔레그램 푸시 +- **플러그인 스킬 메뉴** + - 설치된 Claude Code 플러그인 스킬(superpowers, pr-review-toolkit, octo 등) 부팅 시 자동 스캔 + - `/` 명령어로 텔레그램에 자동 등록, 한글 description 지원 + - `/favorite` 즐겨찾기 핀, 프로젝트별 사용 빈도 기준 자동 정렬 + - `commands/` 디렉터리도 스캔 (`/octo:octo` 등 모든 CLI slash command 포함) +- **MessageBatcher** + - tool-use / thinking 이벤트를 주기적 요약(`⚙️ 작업 중 N건`)으로 묶음 처리 + - `CCBOT_BATCH_WINDOW` 환경 변수로 주기 설정 (기본 10초) +- **DirectMessage 큐** + - 명령어/사진/음성 확인 메시지를 사용자별 큐로 직렬화 + - assistant 응답 사이에 ack 메시지가 끼어드는 현상 제거 +- **`ccbot send` CLI 서브커맨드** + - `ccbot send --session-id "메시지"` / `ccbot send --window <창이름> "메시지"` + - 외부 hook(Stop, PostToolUse 등)에서 텔레그램 API 안 거치고 토픽에 직접 푸시 가능 + - stale window_id guard: `thread_bindings`에 매핑된 wid만 fallback 후보 + +### Changed (기존 동작 변경) + +- README에 fork 차이점 명시 + Changelog 섹션 추가 ([#6](https://github.com/TejNote/ccbot/pull/6)) + +### Fixed (버그 수정) + +- **상태 메시지 좀비 청소** ([#2](https://github.com/TejNote/ccbot/pull/2)) + - `state.json`에 live status message IDs 저장 + - 재시작 시 orphaned `⏳ Working` 메시지 자동 삭제 +- **status polling 안정화** ([#5](https://github.com/TejNote/ccbot/pull/5)) + - background-shell-only 스피너(`Sautéed for 3s · 1 shell still running` 같은 `esc to interrupt` 신호 없는 라인)를 status update로 enqueue하지 않음 + - 턴 종료 후 답변이 마지막 메시지로 안정적으로 남음 +- **status 업데이트 경로 정리** + - content task가 즉시 status를 re-enqueue하지 않고, status polling에 위임 +- **send_keys busy-state guard** + - 수신 pane이 idle인지 먼저 확인하고 전송 → 입력 silent drop 방지 +- **/clear 후 session_map 갱신** + - `/clear` 직후 다음 메시지가 새 세션으로 정상 매핑 +- **batch summary 큐 순회 수정** ([#1](https://github.com/TejNote/ccbot/pull/1)) + - batch summary가 message queue를 정상 통과 +- **hook .env 파싱 보정** + - `.env` 값의 quote 제거, `TMUX_SESSION_NAME` 정규화 + +### Telegram API 제약 대응 + +- 전체 bot command 수를 100개로 cap (Telegram API limit) +- 스킬 description 전체 길이를 Telegram ~5000자 한도 내로 budget + +### Pending upstream merges + +`six-ddc/ccbot:main`에는 있지만 아직 fork에 reconcile 안 된 commit (후속 PR에서 cherry-pick 예정): + +| Upstream commit | 설명 | +| ------------------------------------------------------------------ | -------------------------------------------------------------------- | +| [`865ab89`](https://github.com/six-ddc/ccbot/commit/865ab89) (#67) | Interactive UI 버튼 누를 때 중복 메시지 생성되는 문제 수정 | +| [`350c653`](https://github.com/six-ddc/ccbot/commit/350c653) (#73) | bind 시 사용자가 만든 Telegram 토픽 이름을 rename하지 않도록 수정 | +| [`f5ddd7f`](https://github.com/six-ddc/ccbot/commit/f5ddd7f) | Write tool 결과의 line count 정확히 표시 | + +[Unreleased]: https://github.com/TejNote/ccbot/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/TejNote/ccbot/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 750a7a63..f23cc2f2 100644 --- a/README.md +++ b/README.md @@ -343,44 +343,9 @@ src/ccbot/ ## Changelog (fork) -Tracked here so internal contributors can see what changed relative to upstream `six-ddc/ccbot`. Most recent first. +상세 변경 이력은 [`CHANGELOG.md`](./CHANGELOG.md) 참고. 버전 정책은 [SemVer](https://semver.org/lang/ko/)를 따르고, 포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 기준입니다. -### 2026-05-08 - -- **PR [#5](https://github.com/TejNote/ccbot/pull/5) `fix:` parse_status_line ignores background-shell-only spinners** — Lines like `· Sautéed for 3s · 1 shell still running` (no `esc to interrupt` signal) are no longer enqueued as status updates, eliminating stale status messages that lingered on Telegram after a turn ended. -- **PR [#4](https://github.com/TejNote/ccbot/pull/4) `feat:` Codex topic routing** — `WindowState.provider`, `detect_window_provider`, paste-buffer send path, `parse_codex_status_line`, OMX `ccbot-bridge.mjs` hook, fixtures (`codex_thinking_trace.txt`), and tests (`TestWindowProvider`, `TestResolveRouting`, `TestParseCodexStatusLine`, `test_status_polling_codex.py`). -- **PR [#2](https://github.com/TejNote/ccbot/pull/2) `fix:` keep answer as last message** — Status display delegated to `status_polling`; content tasks no longer immediately re-enqueue a status update. Status message IDs are persisted in `state.json` and orphans cleaned on restart. - -### 2026-05-06 - -- `fix:` `/clear` no longer leaves a stale `session_map` entry — the next message correctly maps to the new session. -- `feat:` skill scanning includes `commands/` directories (e.g. `/octo:octo`, all CLI slash commands) and budgets descriptions to stay under Telegram's ~5000-char total command limit. -- `fix:` total bot-command count capped at 100 (Telegram API limit) with sane truncation. - -### 2026-04-28 - -- `feat:` route command/photo/voice acknowledgements through `enqueue_direct_message` so they don't interleave with Claude responses. -- `feat:` `DirectMessage` task type added to the queue; `enqueue_direct_message` for ordered delivery. - -### 2026-04-27 - -- `feat:` `SkillRegistry` integrated into the bot (`/favorite`, usage tracking, command menu). -- `feat:` `MessageBatcher` for timed grouping of tool-use / thinking events; `CCBOT_BATCH_WINDOW` config. -- `fix:` hook strips quotes from `.env` values and normalizes `TMUX_SESSION_NAME`. -- `fix:` `send_keys` checks Claude busy state before transmission to prevent silent drops. -- PR [#1](https://github.com/TejNote/ccbot/pull/1) `fix:` batch summary correctly traverses the message queue. - -### Pending upstream merges - -These commits are on `six-ddc/ccbot` `main` but not yet reconciled into this fork: - -| Upstream commit | Note | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------- | -| [`865ab89`](https://github.com/six-ddc/ccbot/commit/865ab89) (#67) | Fix interactive UI creating duplicate messages on button press | -| [`350c653`](https://github.com/six-ddc/ccbot/commit/350c653) (#73) | Stop renaming user-created Telegram topics on bind | -| [`f5ddd7f`](https://github.com/six-ddc/ccbot/commit/f5ddd7f) | Show correct line count for Write tool results | - -Plan: cherry-pick or merge in a follow-up PR. +현재 버전: **v1.0.0** (TejNote fork 첫 공식 릴리스, 2026-05-14). ## Contributing back upstream diff --git a/pyproject.toml b/pyproject.toml index f22d0ec7..251a1866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ccbot" -version = "0.1.0" +version = "1.0.0" description = "Telegram Bot for monitoring Claude Code sessions" readme = "README.md" requires-python = ">=3.12"