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 e7ee01dc..f23cc2f2 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,41 +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`) -- **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 ``` @@ -88,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: > @@ -131,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 @@ -139,18 +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 | +| 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):** @@ -161,9 +191,25 @@ 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, fork):** + +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 | +| -------------------------- | ------------------------------------ | +| `/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. Per-project usage counts surface the rest by frequency. + ### Topic Workflow **1 Topic = 1 Window = 1 Session.** The bot runs in Telegram Forum (topics) mode. @@ -180,6 +226,16 @@ Any unrecognized `/command` is also forwarded to Claude Code as-is (e.g. `/revie 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. @@ -208,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. @@ -238,12 +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) | -| `~/.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 @@ -252,31 +309,48 @@ 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 ├── 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) + +상세 변경 이력은 [`CHANGELOG.md`](./CHANGELOG.md) 참고. 버전 정책은 [SemVer](https://semver.org/lang/ko/)를 따르고, 포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 기준입니다. + +현재 버전: **v1.0.0** (TejNote fork 첫 공식 릴리스, 2026-05-14). + +## 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. diff --git a/docs/2026-04-09-message-queue-ordering-design.md b/docs/2026-04-09-message-queue-ordering-design.md new file mode 100644 index 00000000..423398f2 --- /dev/null +++ b/docs/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는 기존 메시지 수정이므로 순서 무관) 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/`) 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: 최종 커밋 (필요 시)** + +수동 검증 중 발견된 수정사항이 있으면 커밋. 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: 커밋 (필요 시)** + +수동 검증 중 발견된 수정사항이 있으면 커밋. 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..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" @@ -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 f8270732..213bea46 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, @@ -115,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, @@ -130,9 +133,13 @@ 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 +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 @@ -148,15 +155,109 @@ # 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 (tg_command → description) +# Keys use the new format: plugin_skilldir (e.g. superpowers_brainstorming) +_SKILL_DESC_KO: dict[str, str] = { + # superpowers + "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 + "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_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_frontend_design": "프론트엔드 UI 디자인 구현", + # octo + "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 + "pr_review_toolkit_staged_review": "단계별 코드 리뷰", } +def _build_bot_commands() -> list[BotCommand]: + """Build the full list of bot commands: built-in + CC + skills.""" + commands = [ + 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)) + if _skill_registry: + # 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) + 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: + 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 + + def is_user_allowed(user_id: int | None) -> bool: return user_id is not None and config.is_user_allowed(user_id) @@ -349,6 +450,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) @@ -516,6 +660,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 @@ -523,7 +679,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": @@ -628,7 +791,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: @@ -704,7 +874,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 @@ -1560,6 +1737,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() @@ -1768,9 +1965,26 @@ 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 + ) + 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, @@ -1809,25 +2023,29 @@ 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) + # 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() @@ -1852,6 +2070,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 +2091,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() @@ -1896,6 +2119,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/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" 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: diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index bdd28038..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 @@ -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() @@ -102,12 +123,14 @@ def get_or_create_queue(bot: Bot, user_id: int) -> asyncio.Queue[MessageTask]: 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() @@ -134,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]: @@ -155,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:] @@ -207,15 +233,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 +255,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 @@ -273,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} @@ -297,6 +328,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 "" @@ -321,7 +386,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 _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: raise @@ -336,7 +400,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 _check_and_send_status(bot, user_id, wid, task.thread_id) return except RetryAfter: raise @@ -381,8 +444,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 _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( @@ -403,6 +466,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: @@ -547,6 +612,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( @@ -564,6 +632,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( @@ -661,6 +730,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/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 eaf9f411..81c5d6a2 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -186,6 +186,27 @@ 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", "") @@ -216,8 +237,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 + + _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().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", @@ -228,8 +266,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) 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/message_batcher.py b/src/ccbot/message_batcher.py new file mode 100644 index 00000000..4a602974 --- /dev/null +++ b/src/ccbot/message_batcher.py @@ -0,0 +1,149 @@ +"""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. + +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 + • 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_queue import enqueue_direct_message +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 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()) + if not entries or elapsed < 5.0: + return + text = _format_batch(entries, elapsed) + chat_id = session_manager.resolve_chat_id(user_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.""" + 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 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) + + +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() 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 173293b1..8b1e2bc2 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -28,11 +28,12 @@ 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 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 @@ -48,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] = { @@ -61,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 @@ -69,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"), ) @@ -110,6 +120,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() @@ -126,6 +139,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) @@ -159,6 +173,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 @@ -191,6 +210,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. @@ -411,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( @@ -531,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", @@ -549,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] @@ -563,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: @@ -735,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( @@ -823,7 +909,26 @@ 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)" - success = await tmux_manager.send_keys(window.window_id, text) + + 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, + 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/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()) diff --git a/src/ccbot/skill_registry.py b/src/ccbot/skill_registry.py new file mode 100644 index 00000000..885d8b21 --- /dev/null +++ b/src/ccbot/skill_registry.py @@ -0,0 +1,260 @@ +"""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 logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ccbot.utils import atomic_write_json + +logger = logging.getLogger(__name__) + + +@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. + + 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) + return [] + + skills: dict[str, SkillInfo] = {} + 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 + 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 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) + 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.""" + 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("-", "_") + cmd = re.sub(r"[^a-z0-9_]", "", cmd) + 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 f"/{command}" + + 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/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index 1afefed0..e8d542b5 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,67 @@ 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 + + +_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. @@ -208,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 @@ -232,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 @@ -263,6 +340,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_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 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 new file mode 100644 index 00000000..caa6b4fc --- /dev/null +++ b/tests/ccbot/test_skill_registry.py @@ -0,0 +1,270 @@ +"""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 == { + "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" + # 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 == "superpowers: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("superpowers_systematic_debugging") + == "/superpowers:systematic-debugging" + ) + + +class TestNameCollision: + 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" + ) + _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("superpowers_zzz_skill") + + sorted_skills = reg.get_sorted_skills() + 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" + _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("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 == "superpowers_zzz_skill" + assert sorted_skills[1].command == "superpowers_aaa_skill" 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..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 ────────────────────────────────────────── @@ -101,6 +144,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 +327,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 + )