From 512bd1952d4a88346118b93d34f61f6d60823c79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 18:16:46 +0000 Subject: [PATCH 01/57] Add CCBOT_BROWSE_ROOT config for directory browser start path New env var sets a fixed starting directory for the directory browser, falling back to Path.cwd() if not set (preserving current behavior). https://claude.ai/code/session_01Vn1pxPc8KahAYpofYGhLjY --- src/ccbot/bot.py | 4 ++-- src/ccbot/config.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 0b746c78..da18cec3 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -812,7 +812,7 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No user.id, thread_id, ) - start_path = str(Path.cwd()) + start_path = config.browse_root or str(Path.cwd()) msg_text, keyboard, subdirs = build_directory_browser(start_path) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY @@ -1251,7 +1251,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - return # Preserve pending thread info, clear only picker state clear_window_picker_state(context.user_data) - start_path = str(Path.cwd()) + start_path = config.browse_root or str(Path.cwd()) msg_text, keyboard, subdirs = build_directory_browser(start_path) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 1dfd28ed..6735abd3 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -93,6 +93,9 @@ def __init__(self) -> None: os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true" ) + # Starting directory for the directory browser + self.browse_root = os.getenv("CCBOT_BROWSE_ROOT", "") + # Scrub sensitive vars from os.environ so child processes never inherit them. # Values are already captured in Config attributes above. for var in SENSITIVE_ENV_VARS: @@ -100,12 +103,13 @@ def __init__(self) -> None: logger.debug( "Config initialized: dir=%s, token=%s..., allowed_users=%d, " - "tmux_session=%s, claude_projects_path=%s", + "tmux_session=%s, claude_projects_path=%s, browse_root=%s", self.config_dir, self.telegram_bot_token[:8], len(self.allowed_users), self.tmux_session_name, self.claude_projects_path, + self.browse_root, ) def is_user_allowed(self, user_id: int) -> bool: From 8eb53bf2327e1a0d31598eaf57867cdf71689d09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 18:19:28 +0000 Subject: [PATCH 02/57] Fix inconsistent Path.cwd() fallbacks in directory browser callbacks Four callback handlers (CB_DIR_SELECT, CB_DIR_UP, CB_DIR_PAGE, CB_DIR_CONFIRM) and build_directory_browser's invalid-path fallback used raw Path.cwd() instead of config.browse_root. This meant users could escape the configured browse root if user_data was lost or the path became invalid during navigation. https://claude.ai/code/session_01Vn1pxPc8KahAYpofYGhLjY --- src/ccbot/bot.py | 8 ++++---- src/ccbot/handlers/directory_browser.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index da18cec3..8e565d09 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -970,7 +970,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - return subdir_name = cached_dirs[idx] - default_path = str(Path.cwd()) + default_path = config.browse_root or str(Path.cwd()) current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1000,7 +1000,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - if pending_tid is not None and _get_thread_id(update) != pending_tid: await query.answer("Stale browser (topic mismatch)", show_alert=True) return - default_path = str(Path.cwd()) + default_path = config.browse_root or str(Path.cwd()) current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1033,7 +1033,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - except ValueError: await query.answer("Invalid data") return - default_path = str(Path.cwd()) + default_path = config.browse_root or str(Path.cwd()) current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1049,7 +1049,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - await query.answer() elif data == CB_DIR_CONFIRM: - default_path = str(Path.cwd()) + default_path = config.browse_root or str(Path.cwd()) selected_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index a9e724d7..bb0ddcad 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -112,7 +112,7 @@ def build_directory_browser( """ path = Path(current_path).expanduser().resolve() if not path.exists() or not path.is_dir(): - path = Path.cwd() + path = Path(config.browse_root) if config.browse_root else Path.cwd() try: subdirs = sorted( From 2f1729e1eb200f15960cea97af6c5d8faf939bda Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 18:42:47 +0000 Subject: [PATCH 03/57] Fix misc bugs: asyncio deprecation, double stat, missing /kill handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.py: Replace deprecated asyncio.get_event_loop() with asyncio.get_running_loop() (Python 3.12+ compat) - session.py: Remove redundant pass statements - session_monitor.py: Consolidate double stat() call into one - screenshot.py: Add explicit parens in _font_tier() for clarity - bot.py: Add /kill command handler — kills tmux window, unbinds thread, cleans up state, and best-effort deletes the topic. Previously the /kill bot command was registered in the menu but had no handler, falling through to forward_command_handler. https://claude.ai/code/session_01Vn1pxPc8KahAYpofYGhLjY --- src/ccbot/bot.py | 59 ++++++++++++++++++++++++++++++++++++ src/ccbot/screenshot.py | 5 ++- src/ccbot/session.py | 6 ++-- src/ccbot/session_monitor.py | 5 +-- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 8e565d09..048f5aed 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -338,6 +338,64 @@ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_reply(update.message, f"```\n{trimmed}\n```") +async def kill_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Kill the associated tmux window, clean up state, and delete the topic.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return + if not update.message: + return + + thread_id = _get_thread_id(update) + if thread_id is None: + await safe_reply(update.message, "❌ This command only works in a topic.") + return + + wid = session_manager.get_window_for_thread(user.id, thread_id) + if not wid: + await safe_reply(update.message, "❌ No session bound to this topic.") + return + + display = session_manager.get_display_name(wid) + + # Kill the tmux window + w = await tmux_manager.find_window_by_id(wid) + if w: + await tmux_manager.kill_window(w.window_id) + logger.info( + "/kill: killed window %s (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + else: + logger.info( + "/kill: window %s already gone (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + + # Unbind and clean up all topic state + session_manager.unbind_thread(user.id, thread_id) + await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + + await safe_reply(update.message, f"✅ Killed session '{display}'.") + + # Best-effort: close and delete the forum topic + chat_id = update.effective_chat.id if update.effective_chat else None + if chat_id and thread_id: + try: + await context.bot.close_forum_topic(chat_id, thread_id) + await context.bot.delete_forum_topic(chat_id, thread_id) + except Exception: + logger.debug( + "/kill: could not close/delete topic (user=%d, thread=%d)", + user.id, + thread_id, + ) + + # --- Screenshot keyboard with quick control keys --- # key_id → (tmux_key, enter, literal) @@ -1631,6 +1689,7 @@ def create_bot() -> Application: application.add_handler(CommandHandler("history", history_command)) application.add_handler(CommandHandler("screenshot", screenshot_command)) application.add_handler(CommandHandler("esc", esc_command)) + application.add_handler(CommandHandler("kill", kill_command)) application.add_handler(CommandHandler("unbind", unbind_command)) application.add_handler(CommandHandler("usage", usage_command)) application.add_handler(CallbackQueryHandler(callback_handler)) diff --git a/src/ccbot/screenshot.py b/src/ccbot/screenshot.py index cbe70f69..c73ef815 100644 --- a/src/ccbot/screenshot.py +++ b/src/ccbot/screenshot.py @@ -104,9 +104,8 @@ def _font_tier(ch: str) -> int: if cp in _SYMBOLA_CODEPOINTS: return 2 # CJK Unified Ideographs + CJK compat + fullwidth forms + Hangul + known Noto-only codepoints - if ( - cp in _NOTO_CODEPOINTS - or cp >= 0x1100 + if cp in _NOTO_CODEPOINTS or ( + cp >= 0x1100 and ( cp <= 0x11FF # Hangul Jamo or 0x2E80 <= cp <= 0x9FFF # CJK radicals, kangxi, ideographs diff --git a/src/ccbot/session.py b/src/ccbot/session.py index c740545c..20b8feeb 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -179,7 +179,6 @@ def _load_state(self) -> None: "Detected old-format state (window_name keys), " "will re-resolve on startup" ) - pass except (json.JSONDecodeError, ValueError) as e: logger.warning("Failed to load state: %s", e) @@ -188,7 +187,6 @@ def _load_state(self) -> None: self.thread_bindings = {} self.window_display_names = {} self.group_chat_ids = {} - pass async def resolve_stale_ids(self) -> None: """Re-resolve persisted window IDs against live tmux windows. @@ -472,8 +470,8 @@ async def wait_for_session_map_entry( timeout, ) key = f"{config.tmux_session_name}:{window_id}" - deadline = asyncio.get_event_loop().time() + timeout - while asyncio.get_event_loop().time() < deadline: + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: try: if config.session_map_file.exists(): async with aiofiles.open(config.session_map_file, "r") as f: diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index 0a1b3186..cee2d3b9 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -291,8 +291,9 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa # For new sessions, initialize offset to end of file # to avoid re-processing old messages try: - file_size = session_info.file_path.stat().st_size - current_mtime = session_info.file_path.stat().st_mtime + st = session_info.file_path.stat() + file_size = st.st_size + current_mtime = st.st_mtime except OSError: file_size = 0 current_mtime = 0.0 From 22b75bea164fea305547b9f720e1993f46fffb7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 20:14:50 +0000 Subject: [PATCH 04/57] Fix duplicate Telegram messages for interactive UI prompts Add timestamp-based deduplication in handle_interactive_ui() to prevent both JSONL monitor and status poller from sending new interactive messages in the same short window. The check-and-set has no await between them, making it atomic in the asyncio event loop. Also add a defensive check in status_polling.py to skip calling handle_interactive_ui() when an interactive message is already tracked for the user/thread (e.g. sent by the JSONL monitor path). https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/handlers/interactive_ui.py | 24 ++++++++++++++++++++++++ src/ccbot/handlers/status_polling.py | 10 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index 174e3a9e..4a275aaf 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -15,6 +15,7 @@ """ import logging +import time from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup @@ -45,6 +46,10 @@ # Track interactive mode: (user_id, thread_id_or_0) -> window_id _interactive_mode: dict[tuple[int, int], str] = {} +# Deduplication: monotonic timestamp of last new interactive message send +_last_interactive_send: dict[tuple[int, int], float] = {} +_INTERACTIVE_DEDUP_WINDOW = 2.0 # seconds — suppress duplicate sends within this window + def get_interactive_window(user_id: int, thread_id: int | None = None) -> str | None: """Get the window_id for user's interactive mode.""" @@ -210,6 +215,23 @@ async def handle_interactive_ui( _interactive_msgs.pop(ikey, None) # Fall through to send new message + # Dedup guard: prevent both JSONL monitor and status poller from sending + # a new interactive message in the same short window. No await between + # check and set, so this is atomic in the asyncio event loop. + last_send = _last_interactive_send.get(ikey, 0.0) + now = time.monotonic() + if now - last_send < _INTERACTIVE_DEDUP_WINDOW: + logger.debug( + "Dedup: skipping duplicate interactive UI send " + "(user=%d, thread=%s, %.1fs since last)", + user_id, + thread_id, + now - last_send, + ) + _interactive_mode[ikey] = window_id + return True + _last_interactive_send[ikey] = now + # Send new message (plain text — terminal content is not markdown) logger.info( "Sending interactive UI to user %d for window_id %s", user_id, window_id @@ -223,6 +245,7 @@ async def handle_interactive_ui( **thread_kwargs, # type: ignore[arg-type] ) except Exception as e: + _last_interactive_send.pop(ikey, None) logger.error("Failed to send interactive UI: %s", e) return False if sent: @@ -241,6 +264,7 @@ async def clear_interactive_msg( ikey = (user_id, thread_id or 0) msg_id = _interactive_msgs.pop(ikey, None) _interactive_mode.pop(ikey, None) + _last_interactive_send.pop(ikey, None) logger.debug( "Clear interactive msg: user=%d, thread=%s, msg_id=%s", user_id, diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index c4de1c6e..326dfc41 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -28,6 +28,7 @@ from ..tmux_manager import tmux_manager from .interactive_ui import ( clear_interactive_msg, + get_interactive_msg_id, get_interactive_window, handle_interactive_ui, ) @@ -93,6 +94,15 @@ async def update_status_message( # Check for permission prompt (interactive UI not triggered via JSONL) # ALWAYS check UI, regardless of skip_status if should_check_new_ui and is_interactive_ui(pane_text): + # Skip if another path (e.g. JSONL monitor) already sent an interactive + # message for this user/thread — avoids duplicate messages + if get_interactive_msg_id(user_id, thread_id): + logger.debug( + "Interactive UI already tracked for user=%d thread=%s, skipping", + user_id, + thread_id, + ) + return logger.debug( "Interactive UI detected in polling (user=%d, window=%s, thread=%s)", user_id, From 1baaad8c7d17e902b086e38d5dc00454ccdf2251 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 21:31:11 +0000 Subject: [PATCH 05/57] Fix iter_thread_bindings RuntimeError: rename to all_thread_bindings returning list snapshot iter_thread_bindings() was a generator yielding from live dicts. Callers with await between iterations (find_users_for_session, status_poll_loop) could allow concurrent unbind_thread() calls to mutate the dict mid-iteration, causing RuntimeError: dictionary changed size during iteration. Fix: rename to all_thread_bindings() returning a materialized list snapshot. The list comprehension captures all (user_id, thread_id, window_id) tuples eagerly, so no live dict reference escapes across await points. Changes: - session.py: iter_thread_bindings -> all_thread_bindings, returns list - bot.py, status_polling.py: update all 4 call sites - Remove unused Iterator import from collections.abc - Add tests: snapshot independence, returns list type, empty bindings https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 2 +- src/ccbot/handlers/status_polling.py | 6 ++--- src/ccbot/session.py | 22 +++++++++-------- tests/ccbot/test_session.py | 35 ++++++++++++++++++++++++++-- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 048f5aed..21b3ac69 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -834,7 +834,7 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No if wid is None: # Unbound topic — check for unbound windows first all_windows = await tmux_manager.list_windows() - bound_ids = {wid for _, _, wid in session_manager.iter_thread_bindings()} + bound_ids = {wid for _, _, wid in session_manager.all_thread_bindings()} unbound = [ (w.window_id, w.window_name, w.cwd) for w in all_windows diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 326dfc41..583b7d84 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -139,9 +139,7 @@ async def status_poll_loop(bot: Bot) -> None: now = time.monotonic() if now - last_topic_check >= TOPIC_CHECK_INTERVAL: last_topic_check = now - for user_id, thread_id, wid in list( - session_manager.iter_thread_bindings() - ): + for user_id, thread_id, wid in session_manager.all_thread_bindings(): try: await bot.unpin_all_forum_topic_messages( chat_id=session_manager.resolve_chat_id(user_id, thread_id), @@ -175,7 +173,7 @@ async def status_poll_loop(bot: Bot) -> None: e, ) - for user_id, thread_id, wid in list(session_manager.iter_thread_bindings()): + for user_id, thread_id, wid in session_manager.all_thread_bindings(): try: # Clean up stale bindings (window no longer exists) w = await tmux_manager.find_window_by_id(wid) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 20b8feeb..d5f0f905 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -17,7 +17,7 @@ Key class: SessionManager (singleton instantiated as `session_manager`). Key methods for thread binding access: - resolve_window_for_thread: Get window_id for a user's thread - - iter_thread_bindings: Generator for iterating all (user_id, thread_id, window_id) + - all_thread_bindings: Snapshot list of all (user_id, thread_id, window_id) - find_users_for_session: Find all users bound to a session_id """ @@ -26,7 +26,6 @@ import logging from dataclasses import dataclass, field from pathlib import Path -from collections.abc import Iterator from typing import Any import aiofiles @@ -737,15 +736,18 @@ def resolve_window_for_thread( return None return self.get_window_for_thread(user_id, thread_id) - def iter_thread_bindings(self) -> Iterator[tuple[int, int, str]]: - """Iterate all thread bindings as (user_id, thread_id, window_id). + def all_thread_bindings(self) -> list[tuple[int, int, str]]: + """Return a snapshot of all thread bindings as (user_id, thread_id, window_id). - Provides encapsulated access to thread_bindings without exposing - the internal data structure directly. + Returns a new list each call so callers can safely await between + iterations without risking ``RuntimeError: dictionary changed size + during iteration`` from a concurrent ``unbind_thread`` call. """ - for user_id, bindings in self.thread_bindings.items(): - for thread_id, window_id in bindings.items(): - yield user_id, thread_id, window_id + return [ + (user_id, thread_id, window_id) + for user_id, bindings in self.thread_bindings.items() + for thread_id, window_id in bindings.items() + ] async def find_users_for_session( self, @@ -756,7 +758,7 @@ async def find_users_for_session( Returns list of (user_id, window_id, thread_id) tuples. """ result: list[tuple[int, str, int]] = [] - for user_id, thread_id, window_id in self.iter_thread_bindings(): + for user_id, thread_id, window_id in self.all_thread_bindings(): resolved = await self.resolve_session_for_window(window_id) if resolved and resolved.session_id == session_id: result.append((user_id, window_id, thread_id)) diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py index 022fb55a..96cfa4a7 100644 --- a/tests/ccbot/test_session.py +++ b/tests/ccbot/test_session.py @@ -25,13 +25,44 @@ def test_bind_unbind_get_returns_none(self, mgr: SessionManager) -> None: def test_unbind_nonexistent_returns_none(self, mgr: SessionManager) -> None: assert mgr.unbind_thread(100, 999) is None - def test_iter_thread_bindings(self, mgr: SessionManager) -> None: + def test_all_thread_bindings(self, mgr: SessionManager) -> None: mgr.bind_thread(100, 1, "@1") mgr.bind_thread(100, 2, "@2") mgr.bind_thread(200, 3, "@3") - result = set(mgr.iter_thread_bindings()) + result = set(mgr.all_thread_bindings()) assert result == {(100, 1, "@1"), (100, 2, "@2"), (200, 3, "@3")} + def test_all_thread_bindings_returns_list(self, mgr: SessionManager) -> None: + """all_thread_bindings must return a list (snapshot), not a generator. + + A generator would hold a live reference into the internal dict and could + raise RuntimeError if an async coroutine calls unbind_thread between two + consumed values. A list snapshot is safe across await points. + """ + mgr.bind_thread(100, 1, "@1") + result = mgr.all_thread_bindings() + assert isinstance(result, list) + + def test_all_thread_bindings_snapshot_is_independent( + self, mgr: SessionManager + ) -> None: + """Mutating thread_bindings after calling all_thread_bindings must not + affect the already-returned snapshot.""" + mgr.bind_thread(100, 1, "@1") + mgr.bind_thread(100, 2, "@2") + snapshot = mgr.all_thread_bindings() + # Mutate the live dict after snapshot was taken — the snapshot must + # be unaffected (this is the property that prevents RuntimeError + # when unbind_thread runs between await points in async callers) + mgr.unbind_thread(100, 1) + assert (100, 1, "@1") in snapshot + assert len(snapshot) == 2 + + def test_all_thread_bindings_empty(self, mgr: SessionManager) -> None: + """all_thread_bindings returns an empty list when nothing is bound.""" + result = mgr.all_thread_bindings() + assert result == [] + class TestGroupChatId: """Tests for group chat_id routing (supergroup forum topic support). From fcb9207f0ae9a771de8c787a8638fe25c28159d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 21:49:33 +0000 Subject: [PATCH 06/57] Replace blocking queue.join() with enqueue_callable for interactive UI queue.join() in handle_new_message blocked the entire monitor loop while waiting for one user's queue to drain. If Telegram was rate-limiting, this could stall all sessions for 30+ seconds. Fix: use enqueue_callable() to push interactive UI handling as a callable task into the queue. The worker executes it in FIFO order after all pending content messages, guaranteeing correct ordering without blocking. Also fixes: - Callable tasks silently dropped during flood control (the guard checked task_type != "content" which matched "callable" too; changed to explicit check for "status_update"/"status_clear" only) - Updated stale docstring in _merge_content_tasks referencing queue.join() https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 56 ++++++++++++++++------------- src/ccbot/handlers/message_queue.py | 39 +++++++++++++++----- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 21b3ac69..ed2dded3 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -105,9 +105,9 @@ ) from .handlers.message_queue import ( clear_status_msg_info, + enqueue_callable, enqueue_content_message, enqueue_status_update, - get_message_queue, shutdown_workers, ) from .handlers.message_sender import ( @@ -1539,30 +1539,38 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: for user_id, wid, thread_id in active_users: # Handle interactive tools specially - capture terminal and send UI if msg.tool_name in INTERACTIVE_TOOL_NAMES and msg.content_type == "tool_use": - # Mark interactive mode BEFORE sleeping so polling skips this window + # Mark interactive mode BEFORE enqueuing so polling skips this window set_interactive_mode(user_id, wid, thread_id) - # Flush pending messages (e.g. plan content) before sending interactive UI - queue = get_message_queue(user_id) - if queue: - await queue.join() - # Wait briefly for Claude Code to render the question UI - await asyncio.sleep(0.3) - handled = await handle_interactive_ui(bot, user_id, wid, thread_id) - if handled: - # Update user's read offset - session = await session_manager.resolve_session_for_window(wid) - if session and session.file_path: - try: - file_size = Path(session.file_path).stat().st_size - session_manager.update_user_window_offset( - user_id, wid, file_size - ) - except OSError: - pass - continue # Don't send the normal tool_use message - else: - # UI not rendered — clear the early-set mode - clear_interactive_mode(user_id, thread_id) + + # Enqueue the interactive UI handling as a callable task so it + # executes AFTER all pending content messages already in the queue, + # without blocking the monitor loop or any other session's processing. + async def _send_interactive_ui( + _bot: Bot = bot, + _user_id: int = user_id, + _wid: str = wid, + _thread_id: int | None = thread_id, + ) -> None: + # Wait briefly for Claude Code to render the question UI + await asyncio.sleep(0.3) + handled = await handle_interactive_ui(_bot, _user_id, _wid, _thread_id) + if handled: + # Update user's read offset + session = await session_manager.resolve_session_for_window(_wid) + if session and session.file_path: + try: + file_size = Path(session.file_path).stat().st_size + session_manager.update_user_window_offset( + _user_id, _wid, file_size + ) + except OSError: + pass + else: + # UI not rendered — clear the early-set mode + clear_interactive_mode(_user_id, _thread_id) + + enqueue_callable(bot, user_id, _send_interactive_ui()) + continue # Don't send the normal tool_use message # Any non-interactive message means the interaction is complete — delete the UI message if get_interactive_msg_id(user_id, thread_id): diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index bdd28038..40c4725c 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -20,8 +20,9 @@ import asyncio import logging import time +from collections.abc import Coroutine from dataclasses import dataclass, field -from typing import Literal +from typing import Any, Literal from telegram import Bot from telegram.constants import ChatAction @@ -55,7 +56,7 @@ def _ensure_formatted(text: str) -> str: class MessageTask: """Message task for queue processing.""" - task_type: Literal["content", "status_update", "status_clear"] + task_type: Literal["content", "status_update", "status_clear", "callable"] text: str | None = None window_id: str | None = None # content type fields @@ -64,6 +65,8 @@ class MessageTask: content_type: str = "text" thread_id: int | None = None # Telegram topic thread_id for targeted send image_data: list[tuple[str, bytes]] | None = None # From tool_result images + # callable task: a no-argument coroutine executed in-order by the worker + callable_fn: Coroutine[Any, Any, None] | None = None # Per-user message queues and worker tasks @@ -144,10 +147,11 @@ async def _merge_content_tasks( additional tasks merged (0 if no merging occurred). Note on queue counter management: - When we put items back, we call task_done() to compensate for the - internal counter increment caused by put_nowait(). This is necessary - because the items were already counted when originally enqueued. - Without this compensation, queue.join() would wait indefinitely. + put_nowait() unconditionally increments _unfinished_tasks. + When we put items back, they already hold a counter slot from when + they were first enqueued, so the compensating task_done() removes the + duplicate increment added by put_nowait(). Without this, _unfinished_tasks + would leak by len(remaining) per merge cycle. """ merged_parts = list(first.parts) current_length = sum(len(p) for p in merged_parts) @@ -212,10 +216,10 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: if flood_end > 0: remaining = flood_end - time.monotonic() if remaining > 0: - if task.task_type != "content": + if task.task_type in ("status_update", "status_clear"): # Status is ephemeral — safe to drop continue - # Content is actual Claude output — wait then send + # Content and callable tasks must not be dropped — wait logger.debug( "Flood controlled: waiting %.0fs for content (user %d)", remaining, @@ -241,6 +245,9 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: await _process_status_update_task(bot, user_id, task) elif task.task_type == "status_clear": await _do_clear_status_message(bot, user_id, task.thread_id or 0) + elif task.task_type == "callable": + if task.callable_fn is not None: + await task.callable_fn except RetryAfter as e: retry_secs = ( e.retry_after @@ -661,6 +668,22 @@ async def enqueue_status_update( queue.put_nowait(task) +def enqueue_callable( + bot: Bot, + user_id: int, + coro: Coroutine[Any, Any, None], +) -> None: + """Enqueue an arbitrary coroutine for in-order execution by the queue worker. + + The coroutine is awaited by the worker after all previously-enqueued tasks + have been processed, guaranteeing ordering without blocking the caller. + The coroutine must accept no arguments (use functools.partial or a closure). + """ + queue = get_or_create_queue(bot, user_id) + task = MessageTask(task_type="callable", callable_fn=coro) + queue.put_nowait(task) + + 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) From e631f73c47b36e581998531487652899ac2e032a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:00:54 +0000 Subject: [PATCH 07/57] Replace destructive unpin_all topic probe with send_chat_action unpin_all_forum_topic_messages was used every 60s to detect deleted topics, but it destructively removed all user-pinned messages as a side effect. Replace with send_chat_action(ChatAction.TYPING) which is ephemeral (5s typing indicator) and raises the same BadRequest("Topic_id_invalid") for deleted topics. All existing error handling works unchanged. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/handlers/status_polling.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 583b7d84..159cef9d 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -5,9 +5,9 @@ - Detects interactive UIs (permission prompts) not triggered via JSONL - Updates status messages in Telegram - Polls thread_bindings (each topic = one window) - - Periodically probes topic existence via unpin_all_forum_topic_messages - (silent no-op when no pins); cleans up deleted topics (kills tmux window - + unbinds thread) + - Periodically probes topic existence via send_chat_action (TYPING); + raises BadRequest on deleted topics; cleans up deleted topics (kills + tmux window + unbinds thread) Key components: - STATUS_POLL_INTERVAL: Polling frequency (1 second) @@ -21,6 +21,7 @@ import time from telegram import Bot +from telegram.constants import ChatAction from telegram.error import BadRequest from ..session import session_manager @@ -141,9 +142,10 @@ async def status_poll_loop(bot: Bot) -> None: last_topic_check = now for user_id, thread_id, wid in session_manager.all_thread_bindings(): try: - await bot.unpin_all_forum_topic_messages( + await bot.send_chat_action( chat_id=session_manager.resolve_chat_id(user_id, thread_id), message_thread_id=thread_id, + action=ChatAction.TYPING, ) except BadRequest as e: if "Topic_id_invalid" in str(e): From 9f7cd6e1c3de9a9f384fff21030c8c1a3b21e5de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:25:18 +0000 Subject: [PATCH 08/57] Add RetryAfter retry loop with callable factory fix - Add MAX_TASK_RETRIES=3 retry loop for short RetryAfter (sleep and retry) - Re-queue tasks on long RetryAfter (>10s) with MAX_REQUEUE_COUNT=5 cap - Convert callable_fn from Coroutine to Callable factory (coroutines are single-use; retry requires a fresh coroutine each attempt) - Catch RetryAfter from _check_and_send_status to prevent cosmetic status updates from triggering content message re-sends - Fix test isolation: clear _last_interactive_send in test fixtures https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 2 +- src/ccbot/handlers/message_queue.py | 147 ++++++++++++++------ tests/ccbot/handlers/test_interactive_ui.py | 8 +- tests/ccbot/handlers/test_status_polling.py | 8 +- 4 files changed, 121 insertions(+), 44 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index ed2dded3..34182081 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1569,7 +1569,7 @@ async def _send_interactive_ui( # UI not rendered — clear the early-set mode clear_interactive_mode(_user_id, _thread_id) - enqueue_callable(bot, user_id, _send_interactive_ui()) + enqueue_callable(bot, user_id, _send_interactive_ui) continue # Don't send the normal tool_use message # Any non-interactive message means the interaction is complete — delete the UI message diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index 40c4725c..3c9d6d7f 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -20,7 +20,7 @@ import asyncio import logging import time -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from typing import Any, Literal @@ -65,8 +65,13 @@ class MessageTask: content_type: str = "text" thread_id: int | None = None # Telegram topic thread_id for targeted send image_data: list[tuple[str, bytes]] | None = None # From tool_result images - # callable task: a no-argument coroutine executed in-order by the worker - callable_fn: Coroutine[Any, Any, None] | None = None + # callable task: a zero-argument coroutine factory executed in-order by the + # worker. Must be a factory (not a bare coroutine object) so the worker can + # safely retry by calling it again — a coroutine can only be awaited once. + callable_fn: Callable[[], Coroutine[Any, Any, None]] | None = None + # Number of times this task has been re-queued after a long RetryAfter. + # Prevents infinite re-queue loops under persistent rate limiting. + requeue_count: int = 0 # Per-user message queues and worker tasks @@ -87,6 +92,12 @@ class MessageTask: # Max seconds to wait for flood control before dropping tasks FLOOD_CONTROL_MAX_WAIT = 10 +# Maximum number of RetryAfter retries per task before giving up +MAX_TASK_RETRIES = 3 + +# Maximum number of times a task can be re-queued after long RetryAfter +MAX_REQUEUE_COUNT = 5 + def get_message_queue(user_id: int) -> asyncio.Queue[MessageTask] | None: """Get the message queue for a user (if exists).""" @@ -230,45 +241,87 @@ 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) + # Retry loop: retry the task on RetryAfter up to MAX_TASK_RETRIES times. + # Merging is done once before the loop so that merged_task is reused on + # every retry attempt rather than re-merging from a now-empty queue. if task.task_type == "content": - # Try to merge consecutive content tasks merged_task, merge_count = await _merge_content_tasks( queue, task, lock ) if merge_count > 0: logger.debug(f"Merged {merge_count} tasks for user {user_id}") - # Mark merged tasks as done for _ in range(merge_count): queue.task_done() - await _process_content_task(bot, user_id, merged_task) - elif task.task_type == "status_update": - await _process_status_update_task(bot, user_id, task) - elif task.task_type == "status_clear": - await _do_clear_status_message(bot, user_id, task.thread_id or 0) - elif task.task_type == "callable": - if task.callable_fn is not None: - await task.callable_fn - except RetryAfter as e: - retry_secs = ( - e.retry_after - if isinstance(e.retry_after, int) - else int(e.retry_after.total_seconds()) - ) - if retry_secs > FLOOD_CONTROL_MAX_WAIT: - _flood_until[user_id] = time.monotonic() + retry_secs - logger.warning( - "Flood control for user %d: retry_after=%ds, " - "pausing queue until ban expires", - user_id, - retry_secs, - ) else: - logger.warning( - "Flood control for user %d: waiting %ds", - user_id, - retry_secs, - ) - await asyncio.sleep(retry_secs) + merged_task = task + merge_count = 0 + + for attempt in range(MAX_TASK_RETRIES + 1): + try: + if merged_task.task_type == "content": + await _process_content_task(bot, user_id, merged_task) + elif merged_task.task_type == "status_update": + await _process_status_update_task(bot, user_id, merged_task) + elif merged_task.task_type == "status_clear": + await _do_clear_status_message( + bot, user_id, merged_task.thread_id or 0 + ) + elif merged_task.task_type == "callable": + if merged_task.callable_fn is not None: + await merged_task.callable_fn() + break # Success — exit retry loop + except RetryAfter as e: + retry_secs = ( + e.retry_after + if isinstance(e.retry_after, int) + else int(e.retry_after.total_seconds()) + ) + if retry_secs > FLOOD_CONTROL_MAX_WAIT: + _flood_until[user_id] = time.monotonic() + retry_secs + if merged_task.requeue_count >= MAX_REQUEUE_COUNT: + logger.error( + "Dropping task for user %d after %d re-queues " + "(persistent flood control, task_type=%s)", + user_id, + merged_task.requeue_count, + merged_task.task_type, + ) + break + merged_task.requeue_count += 1 + logger.warning( + "Flood control for user %d: retry_after=%ds, " + "re-queuing task (requeue %d/%d)", + user_id, + retry_secs, + merged_task.requeue_count, + MAX_REQUEUE_COUNT, + ) + # Re-queue so the task is retried once the ban + # expires. put_nowait increments _unfinished_tasks + # for the new slot; the outer finally calls + # task_done() for the slot consumed by dequeuing, + # so the net counter change is zero. + queue.put_nowait(merged_task) + break # Let the flood-control path handle re-queued task + if attempt < MAX_TASK_RETRIES: + logger.warning( + "RetryAfter for user %d: waiting %ds (attempt %d/%d)", + user_id, + retry_secs, + attempt + 1, + MAX_TASK_RETRIES, + ) + await asyncio.sleep(retry_secs) + # Loop back and retry the same task + else: + logger.error( + "Dropping task for user %d after %d retries " + "(last retry_after=%ds, task_type=%s)", + user_id, + MAX_TASK_RETRIES, + retry_secs, + merged_task.task_type, + ) except Exception as e: logger.error(f"Error processing message task for user {user_id}: {e}") finally: @@ -388,8 +441,14 @@ 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) + # 5. After content, check and send status. + # Catch RetryAfter here: the status message is cosmetic and must never + # propagate RetryAfter to the outer retry loop (which would re-send all + # content messages as duplicates). + try: + await _check_and_send_status(bot, user_id, wid, task.thread_id) + except RetryAfter: + pass async def _convert_status_to_content( @@ -671,16 +730,22 @@ async def enqueue_status_update( def enqueue_callable( bot: Bot, user_id: int, - coro: Coroutine[Any, Any, None], + coro_factory: Callable[[], Coroutine[Any, Any, None]], ) -> None: - """Enqueue an arbitrary coroutine for in-order execution by the queue worker. + """Enqueue a coroutine factory for in-order execution by the queue worker. + + *coro_factory* is a zero-argument callable that returns a new coroutine each + time it is called. The worker calls the factory on each attempt so that + retries after ``RetryAfter`` work correctly (a bare coroutine object can + only be awaited once). + + Typically this is just an async function reference (not its invocation):: - The coroutine is awaited by the worker after all previously-enqueued tasks - have been processed, guaranteeing ordering without blocking the caller. - The coroutine must accept no arguments (use functools.partial or a closure). + enqueue_callable(bot, uid, my_async_fn) # correct — factory + enqueue_callable(bot, uid, my_async_fn()) # WRONG — bare coroutine """ queue = get_or_create_queue(bot, user_id) - task = MessageTask(task_type="callable", callable_fn=coro) + task = MessageTask(task_type="callable", callable_fn=coro_factory) queue.put_nowait(task) diff --git a/tests/ccbot/handlers/test_interactive_ui.py b/tests/ccbot/handlers/test_interactive_ui.py index 8d6a98e4..336f9965 100644 --- a/tests/ccbot/handlers/test_interactive_ui.py +++ b/tests/ccbot/handlers/test_interactive_ui.py @@ -32,13 +32,19 @@ def mock_bot(): @pytest.fixture def _clear_interactive_state(): """Ensure interactive state is clean before and after each test.""" - from ccbot.handlers.interactive_ui import _interactive_mode, _interactive_msgs + from ccbot.handlers.interactive_ui import ( + _interactive_mode, + _interactive_msgs, + _last_interactive_send, + ) _interactive_mode.clear() _interactive_msgs.clear() + _last_interactive_send.clear() yield _interactive_mode.clear() _interactive_msgs.clear() + _last_interactive_send.clear() @pytest.mark.usefixtures("_clear_interactive_state") diff --git a/tests/ccbot/handlers/test_status_polling.py b/tests/ccbot/handlers/test_status_polling.py index 9c0f04f7..ad6ec312 100644 --- a/tests/ccbot/handlers/test_status_polling.py +++ b/tests/ccbot/handlers/test_status_polling.py @@ -24,13 +24,19 @@ def mock_bot(): @pytest.fixture def _clear_interactive_state(): """Ensure interactive state is clean before and after each test.""" - from ccbot.handlers.interactive_ui import _interactive_mode, _interactive_msgs + from ccbot.handlers.interactive_ui import ( + _interactive_mode, + _interactive_msgs, + _last_interactive_send, + ) _interactive_mode.clear() _interactive_msgs.clear() + _last_interactive_send.clear() yield _interactive_mode.clear() _interactive_msgs.clear() + _last_interactive_send.clear() @pytest.mark.usefixtures("_clear_interactive_state") From a845d395090fe9c084588737179fce5d071ad6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:31:37 +0000 Subject: [PATCH 09/57] Remove mtime cache, use size-only fast path for file change detection The _file_mtimes dict used mtime+size to skip unchanged JSONL files, but this introduced edge cases (sub-second writes, clock skew, file replacement). For append-only JSONL files, comparing file size against last_byte_offset is sufficient and eliminates all mtime-related issues. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- .claude/rules/architecture.md | 2 +- .claude/rules/message-handling.md | 2 +- src/ccbot/session_monitor.py | 29 ++++++++--------------------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 320050bd..d8694ebd 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -30,7 +30,7 @@ │ SessionMonitor │ │ TmuxManager (tmux_manager.py) │ │ (session_monitor.py) │ │ - list/find/create/kill windows│ │ - Poll JSONL every 2s │ │ - send_keys to pane │ -│ - Detect mtime changes │ │ - capture_pane for screenshot │ +│ - Detect size changes │ │ - capture_pane for screenshot │ │ - Parse new lines │ └──────────────┬─────────────────┘ │ - Track pending tools │ │ │ across poll cycles │ │ diff --git a/.claude/rules/message-handling.md b/.claude/rules/message-handling.md index ab108a86..33138307 100644 --- a/.claude/rules/message-handling.md +++ b/.claude/rules/message-handling.md @@ -32,7 +32,7 @@ Per-user message queues + worker pattern for all send tasks: ## Performance Optimizations -**mtime cache**: The monitoring loop maintains an in-memory file mtime cache, skipping reads for unchanged files. +**File size fast path**: The monitoring loop compares file size against the last byte offset, skipping reads for unchanged files. **Byte offset incremental reads**: Each tracked session records `last_byte_offset`, reading only new content. File truncation (offset > file_size) is detected and offset is auto-reset. diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index cee2d3b9..160ab4c8 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -6,7 +6,7 @@ 3. Reads new JSONL lines from each session file using byte-offset tracking. 4. Parses entries via TranscriptParser and emits NewMessage objects to a callback. -Optimizations: mtime cache skips unchanged files; byte offset avoids re-reading. +Optimizations: file size check skips unchanged files; byte offset avoids re-reading. Key classes: SessionMonitor, NewMessage, SessionInfo. """ @@ -82,8 +82,6 @@ def __init__( # Track last known session_map for detecting changes # Keys may be window_id (@12) or window_name (old format) during transition 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 def set_message_callback( self, callback: Callable[[NewMessage], Awaitable[None]] @@ -291,43 +289,34 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa # For new sessions, initialize offset to end of file # to avoid re-processing old messages try: - st = session_info.file_path.stat() - file_size = st.st_size - current_mtime = st.st_mtime + file_size = session_info.file_path.stat().st_size except OSError: file_size = 0 - current_mtime = 0.0 tracked = TrackedSession( session_id=session_info.session_id, file_path=str(session_info.file_path), last_byte_offset=file_size, ) self.state.update_session(tracked) - self._file_mtimes[session_info.session_id] = current_mtime logger.info(f"Started tracking session: {session_info.session_id}") continue - # Check mtime + file size to see if file has changed + # Quick size check: skip reading if file size hasn't changed. + # For append-only JSONL files, size == offset means no new + # content. Size < offset (truncation) and size > offset (new + # data) both need processing — handled inside _read_new_lines. try: - st = session_info.file_path.stat() - current_mtime = st.st_mtime - current_size = st.st_size + current_size = session_info.file_path.stat().st_size except OSError: continue - last_mtime = self._file_mtimes.get(session_info.session_id, 0.0) - if ( - current_mtime <= last_mtime - and current_size <= tracked.last_byte_offset - ): - # File hasn't changed, skip reading + if current_size == tracked.last_byte_offset: continue # File changed, read new content from last offset new_entries = await self._read_new_lines( tracked, session_info.file_path ) - self._file_mtimes[session_info.session_id] = current_mtime if new_entries: logger.debug( @@ -417,7 +406,6 @@ async def _cleanup_all_stale_sessions(self) -> None: ) for session_id in stale_sessions: self.state.remove_session(session_id) - self._file_mtimes.pop(session_id, None) self.state.save_if_dirty() async def _detect_and_cleanup_changes(self) -> dict[str, str]: @@ -459,7 +447,6 @@ async def _detect_and_cleanup_changes(self) -> dict[str, str]: if sessions_to_remove: for session_id in sessions_to_remove: self.state.remove_session(session_id) - self._file_mtimes.pop(session_id, None) self.state.save_if_dirty() # Update last known map From 1a2bf77d13f75328f577798abc890c689e7e2055 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:37:28 +0000 Subject: [PATCH 10/57] Move save_if_dirty after message delivery for at-least-once semantics Previously byte offsets were persisted to disk BEFORE delivering messages to Telegram. If the bot crashed after save but before delivery, messages were silently lost. Now offsets are saved AFTER the delivery loop, guaranteeing at-least-once delivery: a crash before save means messages are re-read and re-delivered on restart (safe duplicate) rather than permanently lost. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/session_monitor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index 160ab4c8..3207c114 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -359,7 +359,10 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa except OSError as e: logger.debug(f"Error processing session {session_info.session_id}: {e}") - self.state.save_if_dirty() + # NOTE: save_if_dirty() is intentionally NOT called here. + # Offsets must be persisted only AFTER delivery to Telegram (in + # _monitor_loop) to guarantee at-least-once delivery. Saving before + # delivery would risk silent message loss on crash. return new_messages async def _load_current_session_map(self) -> dict[str, str]: @@ -491,6 +494,12 @@ async def _monitor_loop(self) -> None: except Exception as e: logger.error(f"Message callback error: {e}") + # Persist byte offsets AFTER delivering messages to Telegram. + # This guarantees at-least-once delivery: if the bot crashes + # before this save, messages will be re-read and re-delivered + # on restart (safe duplicate) rather than silently lost. + self.state.save_if_dirty() + except Exception as e: logger.error(f"Monitor loop error: {e}") From ea895e2ea4c7b4bbecfbb86f83768793361e126a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:43:00 +0000 Subject: [PATCH 11/57] Clean up _pending_tools when sessions are removed Dead sessions were cleaned from persistent state but never from the in-memory _pending_tools dict, causing a slow memory leak over time. Add pop() calls in both cleanup paths (startup + runtime). https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/session_monitor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index 3207c114..9bc7e7e6 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -409,6 +409,7 @@ async def _cleanup_all_stale_sessions(self) -> None: ) for session_id in stale_sessions: self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) self.state.save_if_dirty() async def _detect_and_cleanup_changes(self) -> dict[str, str]: @@ -450,6 +451,7 @@ async def _detect_and_cleanup_changes(self) -> dict[str, str]: if sessions_to_remove: for session_id in sessions_to_remove: self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) self.state.save_if_dirty() # Update last known map From 0f701c0b0388e58e336f8f0a94fbc3307059ebff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:47:59 +0000 Subject: [PATCH 12/57] Fix pending message loss when send_to_window fails Previously _pending_thread_text was cleared from user_data BEFORE attempting to send it to the tmux window. If send_to_window() failed, the message was lost and the user had to retype it. Now the pending text is only cleared after a successful send. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 34182081..deded17d 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1181,14 +1181,16 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - created_wname, len(pending_text), ) - if context.user_data is not None: - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_pending_thread_id", None) send_ok, send_msg = await session_manager.send_to_window( created_wid, pending_text, ) - if not send_ok: + if send_ok: + # Clear pending text only after successful send + if context.user_data is not None: + context.user_data.pop("_pending_thread_text", None) + context.user_data.pop("_pending_thread_id", None) + else: logger.warning("Failed to forward pending text: %s", send_msg) await safe_send( context.bot, @@ -1282,14 +1284,16 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - pending_text = ( context.user_data.get("_pending_thread_text") if context.user_data else None ) - if context.user_data is not None: - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_pending_thread_id", None) if pending_text: send_ok, send_msg = await session_manager.send_to_window( selected_wid, pending_text ) - if not send_ok: + if send_ok: + # Clear pending text only after successful send + if context.user_data is not None: + context.user_data.pop("_pending_thread_text", None) + context.user_data.pop("_pending_thread_id", None) + else: logger.warning("Failed to forward pending text: %s", send_msg) await safe_send( context.bot, @@ -1297,6 +1301,9 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - f"❌ Failed to send pending message: {send_msg}", message_thread_id=thread_id, ) + elif context.user_data is not None: + # No pending text — clean up thread_id tracking + context.user_data.pop("_pending_thread_id", None) await query.answer("Bound") # Window picker: new session → transition to directory browser From 922cb4543b629fc7b1499c41021eccb7d998b356 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 22:51:32 +0000 Subject: [PATCH 13/57] Pass message_thread_id to send_chat_action for forum topics Typing indicators in forum topics were silently failing because message_thread_id was not passed to send_chat_action calls. Users in forum topics wouldn't see typing indicators while Claude worked. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/handlers/message_queue.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index 3c9d6d7f..53ff3b63 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -541,7 +541,9 @@ async def _process_status_update_task( if "esc to interrupt" in status_text.lower(): try: await bot.send_chat_action( - chat_id=chat_id, action=ChatAction.TYPING + chat_id=chat_id, + action=ChatAction.TYPING, + message_thread_id=task.thread_id, ) except RetryAfter: raise @@ -600,7 +602,11 @@ async def _do_send_status_message( # Send typing indicator when Claude is working if "esc to interrupt" in text.lower(): try: - await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + await bot.send_chat_action( + chat_id=chat_id, + action=ChatAction.TYPING, + message_thread_id=thread_id, + ) except RetryAfter: raise except Exception: From 7cf121098af619c467c3729b02a97e7c4a9c4e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 02:26:15 +0000 Subject: [PATCH 14/57] Fix overly broad exception handling in handle_interactive_ui The except Exception handler was catching RetryAfter (Telegram 429 rate limiting) and BadRequest("message is not modified"), preventing proper rate limit propagation and causing unnecessary duplicate message sends. Changes: - Re-raise RetryAfter in both edit and send paths so the queue worker retry loop can handle rate limiting correctly - Treat BadRequest "is not modified" as success (content identical) - For other BadRequest errors (message deleted, too old), delete orphan message before falling through to send new - Log exception details in catch-all handler for debugging https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/handlers/interactive_ui.py | 33 +++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index 4a275aaf..158337a2 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -18,6 +18,7 @@ import time from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.error import BadRequest, RetryAfter from ..session import session_manager from ..terminal_parser import extract_interactive_content, is_interactive_ui @@ -207,10 +208,33 @@ async def handle_interactive_ui( ) _interactive_mode[ikey] = window_id return True - except Exception: - # Edit failed (message deleted, etc.) - clear stale msg_id and send new + except RetryAfter: + raise + except BadRequest as e: + if "is not modified" in str(e).lower(): + # Content identical to what's already displayed — treat as success. + _interactive_mode[ikey] = window_id + return True + # Any other BadRequest (e.g. message deleted, too old to edit): + # clear stale state and try to remove the orphan message. + logger.debug( + "Edit failed for interactive msg %s (%s), sending new", + existing_msg_id, + e, + ) + _interactive_msgs.pop(ikey, None) + try: + await bot.delete_message(chat_id=chat_id, message_id=existing_msg_id) + except Exception: + pass # Already deleted or too old — ignore. + # Fall through to send new message + except Exception as e: + # NetworkError, TimedOut, Forbidden, etc. — message state is uncertain; + # discard the stale ID and fall through to send a fresh message. logger.debug( - "Edit failed for interactive msg %s, sending new", existing_msg_id + "Edit failed (%s) for interactive msg %s, sending new", + e, + existing_msg_id, ) _interactive_msgs.pop(ikey, None) # Fall through to send new message @@ -244,6 +268,9 @@ async def handle_interactive_ui( link_preview_options=NO_LINK_PREVIEW, **thread_kwargs, # type: ignore[arg-type] ) + except RetryAfter: + _last_interactive_send.pop(ikey, None) + raise except Exception as e: _last_interactive_send.pop(ikey, None) logger.error("Failed to send interactive UI: %s", e) From 8583d28b1457f52dea9a04b14a51938a62ebcfcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 02:33:15 +0000 Subject: [PATCH 15/57] Add generation counter to prevent stale interactive UI callables When JSONL monitoring enqueues _send_interactive_ui, the callable may execute after the interactive UI has been dismissed. This caused stale callables to potentially send duplicate interactive messages. Fix: introduce a monotonically incrementing generation counter per (user_id, thread_id) key. Every state transition (set_interactive_mode, clear_interactive_mode, clear_interactive_msg) increments the counter. The JSONL monitor captures the generation at enqueue time and passes it to handle_interactive_ui via expected_generation parameter. If the generation has changed by execution time, the function bails out. The status poller is unaffected (passes None, skipping the guard). https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 10 ++++-- src/ccbot/handlers/interactive_ui.py | 46 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index deded17d..4d96b4d9 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1546,8 +1546,9 @@ async def handle_new_message(msg: NewMessage, bot: Bot) -> None: for user_id, wid, thread_id in active_users: # Handle interactive tools specially - capture terminal and send UI if msg.tool_name in INTERACTIVE_TOOL_NAMES and msg.content_type == "tool_use": - # Mark interactive mode BEFORE enqueuing so polling skips this window - set_interactive_mode(user_id, wid, thread_id) + # Mark interactive mode BEFORE enqueuing so polling skips this window. + # Capture the generation so the callable can detect staleness. + gen = set_interactive_mode(user_id, wid, thread_id) # Enqueue the interactive UI handling as a callable task so it # executes AFTER all pending content messages already in the queue, @@ -1557,10 +1558,13 @@ async def _send_interactive_ui( _user_id: int = user_id, _wid: str = wid, _thread_id: int | None = thread_id, + _gen: int = gen, ) -> None: # Wait briefly for Claude Code to render the question UI await asyncio.sleep(0.3) - handled = await handle_interactive_ui(_bot, _user_id, _wid, _thread_id) + handled = await handle_interactive_ui( + _bot, _user_id, _wid, _thread_id, expected_generation=_gen + ) if handled: # Update user's read offset session = await session_manager.resolve_session_for_window(_wid) diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index 158337a2..c90c93dc 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -51,6 +51,17 @@ _last_interactive_send: dict[tuple[int, int], float] = {} _INTERACTIVE_DEDUP_WINDOW = 2.0 # seconds — suppress duplicate sends within this window +# Generation counter: incremented on every state transition (set/clear) so that +# stale callables enqueued by the JSONL monitor can detect invalidation. +_interactive_generation: dict[tuple[int, int], int] = {} + + +def _next_generation(ikey: tuple[int, int]) -> int: + """Increment and return the generation counter for this user/thread.""" + gen = _interactive_generation.get(ikey, 0) + 1 + _interactive_generation[ikey] = gen + return gen + def get_interactive_window(user_id: int, thread_id: int | None = None) -> str | None: """Get the window_id for user's interactive mode.""" @@ -61,21 +72,25 @@ def set_interactive_mode( user_id: int, window_id: str, thread_id: int | None = None, -) -> None: - """Set interactive mode for a user.""" +) -> int: + """Set interactive mode for a user. Returns the generation counter.""" + ikey = (user_id, thread_id or 0) logger.debug( "Set interactive mode: user=%d, window_id=%s, thread=%s", user_id, window_id, thread_id, ) - _interactive_mode[(user_id, thread_id or 0)] = window_id + _interactive_mode[ikey] = window_id + return _next_generation(ikey) def clear_interactive_mode(user_id: int, thread_id: int | None = None) -> None: """Clear interactive mode for a user (without deleting message).""" + ikey = (user_id, thread_id or 0) logger.debug("Clear interactive mode: user=%d, thread=%s", user_id, thread_id) - _interactive_mode.pop((user_id, thread_id or 0), None) + _interactive_mode.pop(ikey, None) + _next_generation(ikey) def get_interactive_msg_id(user_id: int, thread_id: int | None = None) -> int | None: @@ -151,14 +166,36 @@ async def handle_interactive_ui( user_id: int, window_id: str, thread_id: int | None = None, + expected_generation: int | None = None, ) -> bool: """Capture terminal and send interactive UI content to user. Handles AskUserQuestion, ExitPlanMode, Permission Prompt, and RestoreCheckpoint UIs. Returns True if UI was detected and sent, False otherwise. + + If *expected_generation* is provided (from the JSONL monitor path), + the function checks that the current generation still matches before + proceeding. This prevents stale callables from acting after the + interactive mode has been cleared or superseded. """ ikey = (user_id, thread_id or 0) + + # Generation guard: if caller provided an expected generation and it + # doesn't match the current one, this callable is stale — bail out. + if expected_generation is not None: + current_gen = _interactive_generation.get(ikey, 0) + if current_gen != expected_generation: + logger.debug( + "Stale interactive UI callable: user=%d, thread=%s, " + "expected_gen=%d, current_gen=%d — skipping", + user_id, + thread_id, + expected_generation, + current_gen, + ) + return False + chat_id = session_manager.resolve_chat_id(user_id, thread_id) w = await tmux_manager.find_window_by_id(window_id) if not w: @@ -292,6 +329,7 @@ async def clear_interactive_msg( msg_id = _interactive_msgs.pop(ikey, None) _interactive_mode.pop(ikey, None) _last_interactive_send.pop(ikey, None) + _next_generation(ikey) logger.debug( "Clear interactive msg: user=%d, thread=%s, msg_id=%s", user_id, From 5c2033f83d441a48d0bc4f527e525844991face5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 02:36:38 +0000 Subject: [PATCH 16/57] Add clarifying comment for fresh snapshot in status_poll_loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second all_thread_bindings() call gets a fresh snapshot that naturally excludes entries unbound by the topic probe loop above. This is correct behavior, not a bug — add a comment to clarify the intent for future readers. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/handlers/status_polling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 159cef9d..dde0e9ea 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -175,6 +175,8 @@ async def status_poll_loop(bot: Bot) -> None: e, ) + # Fresh snapshot — reflects any unbinds from the topic probe above, + # so bindings cleaned there are naturally excluded. for user_id, thread_id, wid in session_manager.all_thread_bindings(): try: # Clean up stale bindings (window no longer exists) From 5fa7ae4b3f701070c9e0589c3feb58094de2cd27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 02:40:38 +0000 Subject: [PATCH 17/57] Document intentionally ignored wait_for_session_map_entry return value The return value was already handled correctly (proceed regardless), but the ignored bool looked like a bug. Add a comment explaining that on timeout the monitor's 2s poll cycle picks up the entry, and thread binding, pending text, and topic rename work without session_map. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 4d96b4d9..18c7eb1b 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1142,7 +1142,9 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - user.id, pending_thread_id, ) - # Wait for Claude Code's SessionStart hook to register in session_map + # Wait for Claude Code's SessionStart hook to register in session_map. + # Return value intentionally ignored: on timeout, the monitor's poll + # cycle will pick up the session_map entry once the hook fires. await session_manager.wait_for_session_map_entry(created_wid) if pending_thread_id is not None: From ff0ddf36060095f0de0e6d0c3870425cf55ccd4a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 21:12:02 +0000 Subject: [PATCH 18/57] Fix screenshot refresh showing broken preview by switching to photo media Telegram clients fail to re-render document thumbnails when editing document-type media in place via editMessageMedia, causing a "white circle with X" on screenshot refresh. Switch from reply_document + InputMediaDocument to reply_photo + InputMediaPhoto, which Telegram clients handle reliably for inline image edits. Also adds debug logging for the key-press screenshot edit path. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 18c7eb1b..59f4a8b8 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -41,7 +41,7 @@ BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, - InputMediaDocument, + InputMediaPhoto, Update, ) from telegram.constants import ChatAction @@ -229,9 +229,8 @@ async def screenshot_command( png_bytes = await text_to_image(text, with_ansi=True) keyboard = _build_screenshot_keyboard(wid) - await update.message.reply_document( - document=io.BytesIO(png_bytes), - filename="screenshot.png", + await update.message.reply_photo( + photo=io.BytesIO(png_bytes), reply_markup=keyboard, ) @@ -1360,8 +1359,8 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - keyboard = _build_screenshot_keyboard(window_id) try: await query.edit_message_media( - media=InputMediaDocument( - media=io.BytesIO(png_bytes), filename="screenshot.png" + media=InputMediaPhoto( + media=io.BytesIO(png_bytes), ), reply_markup=keyboard, ) @@ -1513,14 +1512,13 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - keyboard = _build_screenshot_keyboard(window_id) try: await query.edit_message_media( - media=InputMediaDocument( + media=InputMediaPhoto( media=io.BytesIO(png_bytes), - filename="screenshot.png", ), reply_markup=keyboard, ) - except Exception: - pass # Screenshot unchanged or message too old + except Exception as e: + logger.debug(f"Screenshot edit after key press failed: {e}") # --- Streaming response / notifications --- From f424a79f8ebf8a7477ef3faece5ebe4350ea435b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 01:23:22 +0000 Subject: [PATCH 19/57] Prevent sending user input to shell when Claude Code has exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check pane_current_command before sending keys to tmux windows. If the pane is running a shell (bash, zsh, etc.), Claude Code has exited and user text must not be forwarded — it would execute as shell commands. Guards added to: send_to_window (safety net), text_handler (with auto-unbind), esc_command, usage_command, and screenshot key-press callback. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 28 +++++++++++++++++++++++++++- src/ccbot/session.py | 15 +++++++++++++-- src/ccbot/tmux_manager.py | 6 ++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 59f4a8b8..b7f53843 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -124,7 +124,7 @@ from .session import session_manager from .session_monitor import NewMessage, SessionMonitor from .terminal_parser import extract_bash_output, is_interactive_ui -from .tmux_manager import tmux_manager +from .tmux_manager import SHELL_COMMANDS, tmux_manager from .utils import ccbot_dir logger = logging.getLogger(__name__) @@ -284,6 +284,9 @@ async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non display = session_manager.get_display_name(wid) await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") return + if w.pane_current_command in SHELL_COMMANDS: + await safe_reply(update.message, "❌ Claude Code has exited.") + return # Send Escape control character (no enter) await tmux_manager.send_keys(w.window_id, "\x1b", enter=False) @@ -308,6 +311,9 @@ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N if not w: await safe_reply(update.message, f"Window '{wid}' no longer exists.") return + if w.pane_current_command in SHELL_COMMANDS: + await safe_reply(update.message, "❌ Claude Code has exited.") + return # Send /usage command to Claude Code TUI await tmux_manager.send_keys(w.window_id, "/usage") @@ -899,6 +905,23 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) return + if w.pane_current_command in SHELL_COMMANDS: + display = session_manager.get_display_name(wid) + logger.info( + "Claude Code exited: window %s running %s, unbinding (user=%d, thread=%d)", + display, + w.pane_current_command, + user.id, + thread_id, + ) + session_manager.unbind_thread(user.id, thread_id) + await safe_reply( + update.message, + f"❌ Claude Code has exited in window '{display}'. Binding removed.\n" + "Send a message to start a new session.", + ) + return + await update.message.chat.send_action(ChatAction.TYPING) await enqueue_status_update(context.bot, user.id, wid, None, thread_id=thread_id) @@ -1498,6 +1521,9 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - if not w: await query.answer("Window not found", show_alert=True) return + if w.pane_current_command in SHELL_COMMANDS: + await query.answer("Claude Code has exited", show_alert=True) + return await tmux_manager.send_keys( w.window_id, tmux_key, enter=enter, literal=literal diff --git a/src/ccbot/session.py b/src/ccbot/session.py index d5f0f905..421eb7ff 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -31,7 +31,7 @@ import aiofiles from .config import config -from .tmux_manager import tmux_manager +from .tmux_manager import SHELL_COMMANDS, tmux_manager from .transcript_parser import TranscriptParser from .utils import atomic_write_json @@ -767,7 +767,10 @@ async def find_users_for_session( # --- Tmux helpers --- async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: - """Send text to a tmux window by ID.""" + """Send text to a tmux window by ID. + + Refuses to send if the pane is running a bare shell (Claude Code exited). + """ display = self.get_display_name(window_id) logger.debug( "send_to_window: window_id=%s (%s), text_len=%d", @@ -778,6 +781,14 @@ 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)" + if window.pane_current_command in SHELL_COMMANDS: + logger.warning( + "Refusing to send keys to %s (%s): pane is running %s (Claude Code exited)", + window_id, + display, + window.pane_current_command, + ) + return False, "Claude Code is not running (session exited)" success = await tmux_manager.send_keys(window.window_id, text) if success: return True, f"Sent to {display}" diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 84cba5aa..8e0bf2da 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -24,6 +24,12 @@ logger = logging.getLogger(__name__) +# Process names that indicate a bare shell (Claude Code has exited). +# Used to prevent sending user input to a shell prompt. +SHELL_COMMANDS = frozenset({ + "bash", "zsh", "sh", "fish", "dash", "tcsh", "csh", "ksh", "ash", +}) + @dataclass class TmuxWindow: From cb455b2eed506740f0e9ae244787e51582117475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 01:30:21 +0000 Subject: [PATCH 20/57] Auto-resume Claude Code when pane drops to shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When send_to_window detects the pane is running a shell, it now captures the pane content and looks for: - "Stopped ... claude" → sends "fg" (suspended process) - "claude --resume " → sends the resume command Waits up to 3s (fg) or 15s (--resume) for Claude Code to take over the terminal, then sends the user's original text. If no resume command is found, the text_handler unbinds the topic and tells the user to start a new session. https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/bot.py | 28 ++++++--------- src/ccbot/session.py | 81 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index b7f53843..ff782aa6 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -905,23 +905,6 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) return - if w.pane_current_command in SHELL_COMMANDS: - display = session_manager.get_display_name(wid) - logger.info( - "Claude Code exited: window %s running %s, unbinding (user=%d, thread=%d)", - display, - w.pane_current_command, - user.id, - thread_id, - ) - session_manager.unbind_thread(user.id, thread_id) - await safe_reply( - update.message, - f"❌ Claude Code has exited in window '{display}'. Binding removed.\n" - "Send a message to start a new session.", - ) - return - await update.message.chat.send_action(ChatAction.TYPING) await enqueue_status_update(context.bot, user.id, wid, None, thread_id=thread_id) @@ -944,7 +927,16 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No success, message = await session_manager.send_to_window(wid, text) if not success: - await safe_reply(update.message, f"❌ {message}") + if "not running" in message: + # Claude Code exited and auto-resume failed — unbind + session_manager.unbind_thread(user.id, thread_id) + await safe_reply( + update.message, + f"❌ {message}. Binding removed.\n" + "Send a message to start a new session.", + ) + else: + await safe_reply(update.message, f"❌ {message}") return # Start background capture for ! bash command output diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 421eb7ff..22ca2464 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -24,6 +24,7 @@ import asyncio import json import logging +import re from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -37,6 +38,27 @@ logger = logging.getLogger(__name__) +# Patterns for detecting Claude Code resume commands in pane output +_RESUME_CMD_RE = re.compile(r"(claude\s+(?:--resume|-r)\s+\S+)") +_STOPPED_RE = re.compile(r"Stopped\s+.*claude", re.IGNORECASE) + + +def _extract_resume_command(pane_text: str) -> str | None: + """Extract a resume command from pane content after Claude Code exit. + + Detects two patterns: + - Suspended process: ``[1]+ Stopped claude`` → returns ``"fg"`` + - Exited with resume hint: ``claude --resume `` → returns the full command + + Returns None if no resume path is detected. + """ + if _STOPPED_RE.search(pane_text): + return "fg" + match = _RESUME_CMD_RE.search(pane_text) + if match: + return match.group(1) + return None + @dataclass class WindowState: @@ -769,7 +791,8 @@ async def find_users_for_session( async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: """Send text to a tmux window by ID. - Refuses to send if the pane is running a bare shell (Claude Code exited). + If the pane is running a bare shell (Claude Code exited), attempts + to auto-resume via ``fg`` or ``claude --resume `` before sending. """ display = self.get_display_name(window_id) logger.debug( @@ -782,18 +805,60 @@ async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: if not window: return False, "Window not found (may have been closed)" if window.pane_current_command in SHELL_COMMANDS: - logger.warning( - "Refusing to send keys to %s (%s): pane is running %s (Claude Code exited)", - window_id, - display, - window.pane_current_command, - ) - return False, "Claude Code is not running (session exited)" + resumed = await self._try_resume_claude(window_id, display) + if not resumed: + return False, "Claude Code is not running (session exited)" success = await tmux_manager.send_keys(window.window_id, text) if success: return True, f"Sent to {display}" return False, "Failed to send keys" + async def _try_resume_claude(self, window_id: str, display: str) -> bool: + """Attempt to resume Claude Code when pane has dropped to a shell. + + Detects ``fg`` (suspended process) and ``claude --resume `` + (exited session) patterns in the pane content. Sends the appropriate + command and waits for Claude Code to take over the terminal. + + Returns True if Claude Code is running after the attempt. + """ + pane_text = await tmux_manager.capture_pane(window_id) + if not pane_text: + return False + + resume_cmd = _extract_resume_command(pane_text) + if not resume_cmd: + logger.warning( + "No resume command found in %s (%s), cannot auto-resume", + window_id, + display, + ) + return False + + logger.info( + "Auto-resuming Claude Code in %s (%s): %s", + window_id, + display, + resume_cmd, + ) + await tmux_manager.send_keys(window_id, resume_cmd) + + # Wait for Claude Code to take over the terminal + max_wait = 3.0 if resume_cmd == "fg" else 15.0 + elapsed = 0.0 + while elapsed < max_wait: + await asyncio.sleep(0.5) + elapsed += 0.5 + w = await tmux_manager.find_window_by_id(window_id) + if w and w.pane_current_command not in SHELL_COMMANDS: + # Claude Code is running again — give TUI a moment to init + await asyncio.sleep(1.0) + logger.info("Claude Code resumed in %s (%s)", window_id, display) + return True + + logger.warning("Auto-resume timed out for %s (%s)", window_id, display) + return False + # --- Message history --- async def get_recent_messages( From f49960aed3e66cf187aa6ec47d052ee7d15af6ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 01:43:36 +0000 Subject: [PATCH 21/57] Optimize tmux performance with list_windows cache and unified capture_pane Reduces tmux subprocess calls from ~120/s to ~21/s with 20 windows by: - Adding 1-second TTL cache to list_windows() (all callers in the same poll cycle share one tmux enumeration instead of N) - Unifying capture_pane() to always use direct `tmux capture-pane` subprocess (plain text mode previously used libtmux which generated 3-4 tmux round-trips per call) - Invalidating cache on mutations (create/kill/rename) https://claude.ai/code/session_016c4b8ioybZyscNayeY6Y18 --- src/ccbot/tmux_manager.py | 115 ++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 8e0bf2da..31a5e775 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -15,6 +15,7 @@ import asyncio import logging +import time from dataclasses import dataclass from pathlib import Path @@ -26,9 +27,19 @@ # Process names that indicate a bare shell (Claude Code has exited). # Used to prevent sending user input to a shell prompt. -SHELL_COMMANDS = frozenset({ - "bash", "zsh", "sh", "fish", "dash", "tcsh", "csh", "ksh", "ash", -}) +SHELL_COMMANDS = frozenset( + { + "bash", + "zsh", + "sh", + "fish", + "dash", + "tcsh", + "csh", + "ksh", + "ash", + } +) @dataclass @@ -44,6 +55,9 @@ class TmuxWindow: class TmuxManager: """Manages tmux windows for Claude Code sessions.""" + # How long cached list_windows results are valid (seconds). + _CACHE_TTL = 1.0 + def __init__(self, session_name: str | None = None): """Initialize tmux manager. @@ -52,6 +66,8 @@ def __init__(self, session_name: str | None = None): """ self.session_name = session_name or config.tmux_session_name self._server: libtmux.Server | None = None + self._windows_cache: list[TmuxWindow] | None = None + self._windows_cache_time: float = 0.0 @property def server(self) -> libtmux.Server: @@ -98,12 +114,23 @@ def _scrub_session_env(session: libtmux.Session) -> None: except Exception: pass # var not set in session env — nothing to remove + def invalidate_cache(self) -> None: + """Invalidate the cached window list (call after mutations).""" + self._windows_cache = None + async def list_windows(self) -> list[TmuxWindow]: """List all windows in the session with their working directories. - Returns: - List of TmuxWindow with window info and cwd + Results are cached for ``_CACHE_TTL`` seconds to avoid hammering + the tmux server when multiple callers need window info in the same + poll cycle. """ + now = time.monotonic() + if ( + self._windows_cache is not None + and (now - self._windows_cache_time) < self._CACHE_TTL + ): + return self._windows_cache def _sync_list_windows() -> list[TmuxWindow]: windows = [] @@ -141,7 +168,10 @@ def _sync_list_windows() -> list[TmuxWindow]: return windows - return await asyncio.to_thread(_sync_list_windows) + result = await asyncio.to_thread(_sync_list_windows) + self._windows_cache = result + self._windows_cache_time = time.monotonic() + return result async def find_window_by_name(self, window_name: str) -> TmuxWindow | None: """Find a window by its name. @@ -178,56 +208,29 @@ async def find_window_by_id(self, window_id: str) -> TmuxWindow | None: async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | None: """Capture the visible text content of a window's active pane. - Args: - window_id: The window ID to capture - with_ansi: If True, capture with ANSI color codes - - Returns: - The captured text, or None on failure. + Uses a direct ``tmux capture-pane`` subprocess for both plain text + and ANSI modes — avoids the multiple tmux round-trips that libtmux + would generate (list-windows → list-panes → capture-pane). """ + cmd = ["tmux", "capture-pane", "-p", "-t", window_id] if with_ansi: - # Use async subprocess to call tmux capture-pane -e for ANSI colors - try: - proc = await asyncio.create_subprocess_exec( - "tmux", - "capture-pane", - "-e", - "-p", - "-t", - window_id, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - if proc.returncode == 0: - return stdout.decode("utf-8") - logger.error( - f"Failed to capture pane {window_id}: {stderr.decode('utf-8')}" - ) - return None - except Exception as e: - logger.error(f"Unexpected error capturing pane {window_id}: {e}") - return None - - # Original implementation for plain text - wrap in thread - def _sync_capture() -> str | None: - session = self.get_session() - if not session: - return None - try: - window = session.windows.get(window_id=window_id) - if not window: - return None - pane = window.active_pane - if not pane: - return None - lines = pane.capture_pane() - return "\n".join(lines) if isinstance(lines, list) else str(lines) - except Exception as e: - logger.error(f"Failed to capture pane {window_id}: {e}") - return None - - return await asyncio.to_thread(_sync_capture) + cmd.insert(2, "-e") + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return stdout.decode("utf-8") + logger.error( + "Failed to capture pane %s: %s", window_id, stderr.decode("utf-8") + ) + return None + except Exception as e: + logger.error("Unexpected error capturing pane %s: %s", window_id, e) + return None async def send_keys( self, window_id: str, text: str, enter: bool = True, literal: bool = True @@ -332,6 +335,7 @@ def _sync_send_keys() -> bool: async def rename_window(self, window_id: str, new_name: str) -> bool: """Rename a tmux window by its ID.""" + self.invalidate_cache() def _sync_rename() -> bool: session = self.get_session() @@ -352,6 +356,7 @@ def _sync_rename() -> bool: async def kill_window(self, window_id: str) -> bool: """Kill a tmux window by its ID.""" + self.invalidate_cache() def _sync_kill() -> bool: session = self.get_session() @@ -404,6 +409,8 @@ async def create_window( counter += 1 # Create window in thread + self.invalidate_cache() + def _create_and_start() -> tuple[bool, str, str, str]: session = self.get_or_create_session() try: From 31cc95d10125085b5abd8638a9ee9a3de7590759 Mon Sep 17 00:00:00 2001 From: JanusMarko <96794314+JanusMarko@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:27:06 -0500 Subject: [PATCH 22/57] Create ccbot-workshop-setup.md --- ccbot-workshop-setup.md | 310 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 ccbot-workshop-setup.md diff --git a/ccbot-workshop-setup.md b/ccbot-workshop-setup.md new file mode 100644 index 00000000..bef0cec5 --- /dev/null +++ b/ccbot-workshop-setup.md @@ -0,0 +1,310 @@ +# CCBot Workshop Setup Guide + +Complete setup from a fresh Windows machine to running CCBot with Claude Code sessions accessible via Telegram. + +--- + +## Prerequisites + +Before you begin, you'll need: + +- Windows 10 (version 2004+) or Windows 11 +- A Telegram account +- A Claude Code subscription (Claude Pro/Team/Enterprise) +- Your project repositories cloned into `C:\GitHub\` + +--- + +## Part 1: Install WSL and Ubuntu + +Open **PowerShell as Administrator** and run: + +```powershell +wsl --install +``` + +This installs WSL 2 with Ubuntu. Restart your computer when prompted. + +After restart, Ubuntu will open automatically and ask you to create a username and password. Remember these — you'll need the password for `sudo` commands. + +Once you're at the Ubuntu prompt, update everything: + +```bash +sudo apt update && sudo apt upgrade -y +``` + +--- + +## Part 2: Install Core Tools + +### Node.js (required for Claude Code) + +```bash +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs +``` + +Verify: + +```bash +node --version +npm --version +``` + +### Claude Code + +```bash +npm install -g @anthropic-ai/claude-code +``` + +Add npm global bin to your PATH if not already there: + +```bash +echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc +source ~/.bashrc +``` + +Verify Claude Code works: + +```bash +claude --version +``` + +### tmux + +```bash +sudo apt install -y tmux +``` + +### uv (Python package manager) + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Then: + +```bash +source ~/.bashrc +``` + +--- + +## Part 3: Create a Telegram Bot + +1. Open Telegram and search for **@BotFather** +2. Send `/newbot` +3. Follow the prompts to name your bot +4. BotFather gives you a **bot token** — save it (looks like `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### Get your Telegram user ID + +1. Search for **@userinfobot** in Telegram +2. Start a chat with it +3. It replies with your numeric user ID — save it + +### Create a Telegram group + +1. Create a new group in Telegram +2. Name it something like "Workshop Sessions" +3. Add your bot to the group +4. Go to group settings → **Topics** → Enable topics (use list format) +5. Make the bot an **admin** of the group + +--- + +## Part 4: Install CCBot Workshop + +```bash +uv tool install git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +Verify it installed: + +```bash +which ccbot +``` + +If it's not found, add the path: + +```bash +export PATH="$HOME/.local/bin:$PATH" +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +``` + +### Configure CCBot + +Create the config directory and environment file: + +```bash +mkdir -p ~/.ccbot +nano ~/.ccbot/.env +``` + +Paste the following, replacing the placeholder values with your actual token and user ID: + +``` +TELEGRAM_BOT_TOKEN=your_bot_token_here +ALLOWED_USERS=your_telegram_user_id_here +TMUX_SESSION_NAME=ccbot +CLAUDE_COMMAND=claude --dangerously-skip-permissions +CCBOT_BROWSE_ROOT=/mnt/c/GitHub +``` + +Save with `Ctrl+O`, exit with `Ctrl+X`. + +The `CCBOT_BROWSE_ROOT` setting ensures the directory browser always starts from your `C:\GitHub\` folder when creating new sessions. + +### Install the Claude Code hook + +This lets CCBot track which Claude session runs in which tmux window: + +```bash +ccbot hook --install +``` + +Or manually add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [{ "type": "command", "command": "ccbot hook", "timeout": 5 }] + } + ] + } +} +``` + +--- + +## Part 5: Starting CCBot + +### First time startup + +```bash +tmux new -s ccbot +``` + +Inside the tmux session: + +```bash +ccbot +``` + +You should see log output confirming the bot started, including your allowed users and Claude projects path. + +### Detach from tmux + +Press `Ctrl+b`, release, then press `d`. CCBot keeps running in the background. You can close the terminal — it stays alive. + +### Start a session from Telegram + +1. Open your Telegram group +2. Create a new topic (name it after your project, e.g. "PAIOS") +3. Send a message in the topic +4. CCBot shows a directory browser starting from `C:\GitHub\` — tap your project folder +5. Tap **Select** to confirm +6. A new tmux window is created with Claude Code running in that directory +7. Your message is forwarded to Claude Code + +### View sessions in the terminal + +```bash +tmux attach -t ccbot +``` + +Switch between windows using `Ctrl+b` then the window number (shown in the bottom bar). For example: + +- `Ctrl+b` then `1` → ccbot process (don't close this) +- `Ctrl+b` then `2` → first Claude Code session +- `Ctrl+b` then `3` → second Claude Code session + +Detach again with `Ctrl+b` then `d`. + +--- + +## Part 6: Daily Usage + +### Starting CCBot after a reboot + +```bash +tmux new -s ccbot || tmux attach -t ccbot +ccbot +``` + +Then `Ctrl+b` then `d` to detach. + +### Useful Telegram commands + +Send these in a topic: + +- `/screenshot` — see what the terminal looks like right now +- `/history` — browse conversation history +- `/esc` — send Escape key (toggles plan mode, same as Shift+Tab) +- `/cost` — check token usage +- `/kill` — kill the session and delete the topic + +### Ending a session + +Close or delete the topic in Telegram. The tmux window is automatically killed. + +### Multiple projects + +Create a new topic for each project. CCBot's design is **1 topic = 1 window = 1 session**. Each topic can run a separate Claude Code session in a different project directory. + +### Switching between phone and desktop + +From your phone, just use Telegram — all interaction goes through topics. + +To switch to your desktop terminal: + +```bash +tmux attach -t ccbot +``` + +Navigate to the right window with `Ctrl+b` then the window number. You're in the same session with full scrollback. + +--- + +## Part 7: Uninstall and Reinstall + +Use this after pushing updates to your fork. + +### Stop CCBot + +```bash +tmux attach -t ccbot +``` + +Press `Ctrl+C` to stop ccbot. Stay in the tmux session. + +### Uninstall the current version + +```bash +uv tool uninstall ccbot +``` + +### Install the updated version + +```bash +uv tool install git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +If you're getting a cached version and not seeing your changes, force a fresh install: + +```bash +uv tool install --force git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +### Verify and restart + +```bash +which ccbot +ccbot +``` + +Then `Ctrl+b` then `d` to detach. + +Your `~/.ccbot/.env` configuration and `~/.ccbot/state.json` session state are preserved across reinstalls — you don't need to reconfigure anything. From 2f1e6ab6e9c5842d9c0ca1f5be16bcc18be01a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 22:11:15 +0000 Subject: [PATCH 23/57] Fix duplicate interactive UI messages for numbered answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an assistant message contains both text blocks and an interactive tool_use (ExitPlanMode/AskUserQuestion), the text entries were processed first in handle_new_message, clearing the interactive UI state set by the status poller. This caused the JSONL callable to send a second interactive message instead of editing the existing one. Fix: pre-scan assistant messages for interactive tools and suppress text block emission when present — the terminal capture already includes that preamble text. https://claude.ai/code/session_01WHUN1GLBFr2ZkuEmeVtuPW --- src/ccbot/transcript_parser.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ccbot/transcript_parser.py b/src/ccbot/transcript_parser.py index fa0bbf69..8bb4fc1f 100644 --- a/src/ccbot/transcript_parser.py +++ b/src/ccbot/transcript_parser.py @@ -486,6 +486,22 @@ def parse_entries( last_cmd_name = None if msg_type == "assistant": + # Pre-scan: check if this message contains an interactive + # tool_use (ExitPlanMode / AskUserQuestion). When present, + # suppress text entries from this same message — those text + # blocks are preamble that the terminal capture already + # includes. Emitting them as separate content messages + # causes a race: the content message clears the interactive + # UI state set by the status poller, leading to a duplicate + # interactive message being sent by the JSONL callable. + _INTERACTIVE_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"}) + has_interactive_tool = any( + isinstance(b, dict) + and b.get("type") == "tool_use" + and b.get("name") in _INTERACTIVE_TOOLS + for b in content + ) + # Process content blocks has_text = False for block in content: @@ -494,6 +510,11 @@ def parse_entries( btype = block.get("type", "") if btype == "text": + # Skip text blocks when an interactive tool_use is + # present in the same message to avoid clearing the + # interactive UI state prematurely. + if has_interactive_tool: + continue t = block.get("text", "").strip() if t and t != cls._NO_CONTENT_PLACEHOLDER: result.append( From 291381dd2f3a0835ef93256bb4b25db33fafb113 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 00:51:19 +0000 Subject: [PATCH 24/57] Add OOM detection and memory monitoring for tmux sessions When a tmux window dies, the status poller now checks dmesg for OOM kills matching the window's process tree and notifies the user in the Telegram topic with the reason (OOM vs normal exit). Optionally monitors RSS memory of Claude processes and warns when usage exceeds threshold. - Add process_info.py: /proc-based process tree, RSS reading, dmesg OOM parsing - Add TmuxManager.get_pane_pid() for correlating windows to processes - Track pane PIDs in status poller for post-mortem OOM detection - Send session-end notification to user topic on window death - Add opt-in memory monitoring (CCBOT_MEMORY_MONITOR=true) - Config: CCBOT_MEMORY_WARNING_MB (default 2048), CCBOT_MEMORY_CHECK_INTERVAL (30s) https://claude.ai/code/session_01WHUN1GLBFr2ZkuEmeVtuPW --- src/ccbot/config.py | 9 ++ src/ccbot/handlers/status_polling.py | 112 +++++++++++++++++++ src/ccbot/process_info.py | 159 +++++++++++++++++++++++++++ src/ccbot/tmux_manager.py | 22 ++++ tests/ccbot/test_process_info.py | 156 ++++++++++++++++++++++++++ 5 files changed, 458 insertions(+) create mode 100644 src/ccbot/process_info.py create mode 100644 tests/ccbot/test_process_info.py diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 6735abd3..841a4f7d 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -96,6 +96,15 @@ def __init__(self) -> None: # Starting directory for the directory browser self.browse_root = os.getenv("CCBOT_BROWSE_ROOT", "") + # Memory monitoring (opt-in) + self.memory_monitor_enabled = ( + os.getenv("CCBOT_MEMORY_MONITOR", "").lower() == "true" + ) + self.memory_warning_mb = float(os.getenv("CCBOT_MEMORY_WARNING_MB", "2048")) + self.memory_check_interval = float( + os.getenv("CCBOT_MEMORY_CHECK_INTERVAL", "30") + ) + # Scrub sensitive vars from os.environ so child processes never inherit them. # Values are already captured in Config attributes above. for var in SENSITIVE_ENV_VARS: diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index dde0e9ea..7fb264d2 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -24,6 +24,8 @@ from telegram.constants import ChatAction from telegram.error import BadRequest +from ..config import config +from ..process_info import get_process_tree_pids, get_tree_rss_mb, was_pid_oom_killed from ..session import session_manager from ..terminal_parser import is_interactive_ui, parse_status_line from ..tmux_manager import tmux_manager @@ -35,9 +37,17 @@ ) from .cleanup import clear_topic_state from .message_queue import enqueue_status_update, get_message_queue +from .message_sender import safe_send logger = logging.getLogger(__name__) +# Track pane PIDs so we can check OOM after window death +_window_pids: dict[str, int] = {} # window_id → shell PID + +# Memory monitoring state +_last_memory_check: float = 0.0 +_memory_warned: set[str] = set() # window_ids that have been warned + # Status polling interval STATUS_POLL_INTERVAL = 1.0 # seconds - faster response (rate limiting at send layer) @@ -182,6 +192,54 @@ async def status_poll_loop(bot: Bot) -> None: # Clean up stale bindings (window no longer exists) w = await tmux_manager.find_window_by_id(wid) if not w: + display_name = session_manager.get_display_name(wid) + shell_pid = _window_pids.pop(wid, None) + _memory_warned.discard(wid) + + # Check if the window was killed by OOM + reason = "Session ended" + if shell_pid: + try: + descendants = await get_process_tree_pids(shell_pid) + except Exception: + descendants = [] + oom_info = await was_pid_oom_killed(shell_pid, descendants) + if oom_info: + rss_kb = oom_info.get("rss_kb") + rss_str = ( + f", RSS: {int(str(rss_kb)) // 1024}MB" + if rss_kb + else "" + ) + reason = ( + f"Session killed by OOM killer " + f"(process: {oom_info['process_name']}" + f"{rss_str})" + ) + logger.warning( + "OOM kill detected for window %s (pid=%d): %s", + wid, + shell_pid, + oom_info.get("line", ""), + ) + + # Notify user in the topic + try: + chat_id = session_manager.resolve_chat_id( + user_id, thread_id + ) + await safe_send( + bot, + chat_id, + f"\u26a0\ufe0f {reason}: {display_name}", + message_thread_id=thread_id, + ) + except Exception as e: + logger.debug( + "Failed to send session-end notification: %s", + e, + ) + session_manager.unbind_thread(user_id, thread_id) await clear_topic_state(user_id, thread_id, bot) logger.info( @@ -192,6 +250,12 @@ async def status_poll_loop(bot: Bot) -> None: ) continue + # Track pane PID for OOM detection on death + if wid not in _window_pids: + pid = await tmux_manager.get_pane_pid(wid) + if pid: + _window_pids[wid] = pid + # UI detection happens unconditionally in update_status_message. # Status enqueue is skipped inside update_status_message when # interactive UI is detected (returns early) or when queue is non-empty. @@ -210,7 +274,55 @@ async def status_poll_loop(bot: Bot) -> None: f"Status update error for user {user_id} " f"thread {thread_id}: {e}" ) + + # Periodic memory monitoring (opt-in) + await _check_memory_usage(bot) except Exception as e: logger.error(f"Status poll loop error: {e}") await asyncio.sleep(STATUS_POLL_INTERVAL) + + +async def _check_memory_usage(bot: Bot) -> None: + """Check memory usage of all tracked windows and warn if above threshold.""" + global _last_memory_check + + if not config.memory_monitor_enabled: + return + + now = time.monotonic() + if now - _last_memory_check < config.memory_check_interval: + return + _last_memory_check = now + + for user_id, thread_id, wid in session_manager.all_thread_bindings(): + pid = _window_pids.get(wid) + if not pid: + continue + try: + rss_mb = await get_tree_rss_mb(pid) + if rss_mb is None: + continue + + if rss_mb > config.memory_warning_mb and wid not in _memory_warned: + _memory_warned.add(wid) + display_name = session_manager.get_display_name(wid) + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send( + bot, + chat_id, + f"\u26a0\ufe0f High memory usage: {display_name} " + f"is using {rss_mb:.0f}MB RSS", + message_thread_id=thread_id, + ) + logger.warning( + "Memory warning for window %s (pid=%d): %.0fMB", + wid, + pid, + rss_mb, + ) + elif rss_mb <= config.memory_warning_mb * 0.8 and wid in _memory_warned: + # Reset warning when memory drops to 80% of threshold + _memory_warned.discard(wid) + except Exception as e: + logger.debug("Memory check error for window %s: %s", wid, e) diff --git a/src/ccbot/process_info.py b/src/ccbot/process_info.py new file mode 100644 index 00000000..750db2a7 --- /dev/null +++ b/src/ccbot/process_info.py @@ -0,0 +1,159 @@ +"""Process tree inspection, memory usage, and OOM-kill detection. + +Provides Linux-specific helpers that read /proc and dmesg to: + - Walk a process's descendant tree + - Read RSS memory (VmRSS) for a process or its entire tree + - Detect recent OOM kills and correlate them with specific PIDs + +All functions are async-friendly (blocking I/O wrapped in to_thread). +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from pathlib import Path + +logger = logging.getLogger(__name__) + + +async def get_child_pids(pid: int) -> list[int]: + """Get direct child PIDs of a process via /proc/[pid]/task/[tid]/children.""" + children: list[int] = [] + task_dir = Path(f"/proc/{pid}/task") + try: + if not task_dir.exists(): + return children + for tid_dir in task_dir.iterdir(): + children_file = tid_dir / "children" + if children_file.exists(): + text = children_file.read_text().strip() + if text: + children.extend(int(p) for p in text.split()) + except (OSError, ValueError): + pass + return children + + +async def get_process_tree_pids(root_pid: int) -> list[int]: + """Get all descendant PIDs of a process (breadth-first).""" + all_pids: list[int] = [] + queue = [root_pid] + while queue: + pid = queue.pop(0) + kids = await get_child_pids(pid) + all_pids.extend(kids) + queue.extend(kids) + return all_pids + + +async def get_process_rss_mb(pid: int) -> float | None: + """Read VmRSS from /proc/[pid]/status. Returns MB or None if unavailable.""" + try: + status_file = Path(f"/proc/{pid}/status") + if not status_file.exists(): + return None + text = await asyncio.to_thread(status_file.read_text) + for line in text.splitlines(): + if line.startswith("VmRSS:"): + # Format: "VmRSS: 12345 kB" + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) / 1024.0 + except (OSError, ValueError): + pass + return None + + +async def get_tree_rss_mb(root_pid: int) -> float | None: + """Sum RSS of a process and all its descendants. Returns MB or None.""" + root_rss = await get_process_rss_mb(root_pid) + if root_rss is None: + return None + + total = root_rss + descendants = await get_process_tree_pids(root_pid) + for pid in descendants: + rss = await get_process_rss_mb(pid) + if rss is not None: + total += rss + return total + + +def _parse_dmesg_for_oom_kills(dmesg_output: str) -> list[dict[str, object]]: + """Parse dmesg text for OOM kill entries. + + Returns list of dicts with keys: pid, process_name, total_pages, rss_kb. + """ + results: list[dict[str, object]] = [] + # Kernel OOM killer log pattern (varies by kernel version): + # "Killed process 1234 (python3) total-vm:123456kB, ..." + # "Out of memory: Killed process 1234 (python3) ..." + pattern = re.compile( + r"Killed process (\d+) \(([^)]+)\)" + r"(?:.*?total-vm:(\d+)kB)?" + r"(?:.*?anon-rss:(\d+)kB)?", + ) + for line in dmesg_output.splitlines(): + m = pattern.search(line) + if m: + results.append( + { + "pid": int(m.group(1)), + "process_name": m.group(2), + "total_vm_kb": int(m.group(3)) if m.group(3) else None, + "rss_kb": int(m.group(4)) if m.group(4) else None, + "line": line.strip(), + } + ) + return results + + +async def check_recent_oom_kills() -> list[dict[str, object]]: + """Check dmesg for OOM kill entries. + + Returns all OOM kills found in the current dmesg buffer. + Best-effort: returns empty list if dmesg is inaccessible. + """ + try: + proc = await asyncio.create_subprocess_exec( + "dmesg", + "--level=err,warn,info", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + # Try without --level flag (older dmesg versions) + proc = await asyncio.create_subprocess_exec( + "dmesg", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + return [] + return _parse_dmesg_for_oom_kills(stdout.decode("utf-8", errors="replace")) + except Exception as e: + logger.debug("Failed to check dmesg for OOM kills: %s", e) + return [] + + +async def was_pid_oom_killed( + shell_pid: int, + descendants: list[int] | None = None, +) -> dict[str, object] | None: + """Check if a PID or any of its descendants were OOM-killed. + + Returns the matching OOM kill info dict, or None. + """ + pids_to_check = {shell_pid} + if descendants: + pids_to_check.update(descendants) + + oom_kills = await check_recent_oom_kills() + for kill in oom_kills: + if kill["pid"] in pids_to_check: + return kill + return None diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 31a5e775..4c6dcb70 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -205,6 +205,28 @@ async def find_window_by_id(self, window_id: str) -> TmuxWindow | None: logger.debug("Window not found by id: %s", window_id) return None + async def get_pane_pid(self, window_id: str) -> int | None: + """Get the PID of the shell process in a window's active pane.""" + try: + proc = await asyncio.create_subprocess_exec( + "tmux", + "display-message", + "-p", + "-t", + window_id, + "#{pane_pid}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + pid_str = stdout.decode("utf-8").strip() + if pid_str: + return int(pid_str) + except (OSError, ValueError) as e: + logger.debug("Failed to get pane PID for %s: %s", window_id, e) + return None + async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | None: """Capture the visible text content of a window's active pane. diff --git a/tests/ccbot/test_process_info.py b/tests/ccbot/test_process_info.py new file mode 100644 index 00000000..e637730b --- /dev/null +++ b/tests/ccbot/test_process_info.py @@ -0,0 +1,156 @@ +"""Tests for process_info module — OOM detection and memory utilities.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from ccbot.process_info import ( + _parse_dmesg_for_oom_kills, + get_process_rss_mb, + was_pid_oom_killed, +) + + +class TestParseDmesgForOomKills: + """Test dmesg OOM-kill line parsing.""" + + def test_standard_oom_kill_line(self) -> None: + line = ( + "[12345.678] Out of memory: Killed process 1234 (python3) " + "total-vm:15000000kB, anon-rss:14800000kB, file-rss:1234kB" + ) + results = _parse_dmesg_for_oom_kills(line) + assert len(results) == 1 + assert results[0]["pid"] == 1234 + assert results[0]["process_name"] == "python3" + assert results[0]["total_vm_kb"] == 15000000 + assert results[0]["rss_kb"] == 14800000 + + def test_minimal_oom_kill_line(self) -> None: + line = "[12345.678] Killed process 5678 (node)" + results = _parse_dmesg_for_oom_kills(line) + assert len(results) == 1 + assert results[0]["pid"] == 5678 + assert results[0]["process_name"] == "node" + assert results[0]["total_vm_kb"] is None + assert results[0]["rss_kb"] is None + + def test_multiple_oom_kills(self) -> None: + text = ( + "[100.0] Killed process 111 (a)\n" + "[200.0] some other log line\n" + "[300.0] Killed process 222 (b)\n" + ) + results = _parse_dmesg_for_oom_kills(text) + assert len(results) == 2 + assert results[0]["pid"] == 111 + assert results[1]["pid"] == 222 + + def test_no_oom_kills(self) -> None: + text = "some normal log output\nanother line\n" + results = _parse_dmesg_for_oom_kills(text) + assert len(results) == 0 + + def test_empty_input(self) -> None: + assert _parse_dmesg_for_oom_kills("") == [] + + +class TestGetProcessRssMb: + """Test reading process RSS from /proc.""" + + @pytest.mark.asyncio + async def test_reads_vmrss(self, tmp_path: object) -> None: + status_content = ( + "Name:\tpython3\n" + "VmPeak:\t1000000 kB\n" + "VmRSS:\t512000 kB\n" + "VmSize:\t800000 kB\n" + ) + with patch("ccbot.process_info.Path") as mock_path: + mock_file = mock_path.return_value + mock_file.exists.return_value = True + mock_file.read_text.return_value = status_content + result = await get_process_rss_mb(1234) + assert result is not None + assert abs(result - 500.0) < 0.1 # 512000 kB = 500 MB + + @pytest.mark.asyncio + async def test_returns_none_for_missing_process(self) -> None: + with patch("ccbot.process_info.Path") as mock_path: + mock_file = mock_path.return_value + mock_file.exists.return_value = False + result = await get_process_rss_mb(99999) + assert result is None + + +class TestWasPidOomKilled: + """Test OOM-kill correlation with specific PIDs.""" + + @pytest.mark.asyncio + async def test_detects_oom_for_matching_pid(self) -> None: + oom_kills = [ + { + "pid": 1234, + "process_name": "python3", + "total_vm_kb": 15000000, + "rss_kb": 14800000, + "line": "Killed process 1234 (python3)", + } + ] + with patch( + "ccbot.process_info.check_recent_oom_kills", + new_callable=AsyncMock, + return_value=oom_kills, + ): + result = await was_pid_oom_killed(1234) + assert result is not None + assert result["pid"] == 1234 + + @pytest.mark.asyncio + async def test_detects_oom_for_descendant_pid(self) -> None: + oom_kills = [ + { + "pid": 5678, + "process_name": "node", + "total_vm_kb": None, + "rss_kb": None, + "line": "Killed process 5678 (node)", + } + ] + with patch( + "ccbot.process_info.check_recent_oom_kills", + new_callable=AsyncMock, + return_value=oom_kills, + ): + result = await was_pid_oom_killed(1234, descendants=[5678, 9999]) + assert result is not None + assert result["pid"] == 5678 + + @pytest.mark.asyncio + async def test_returns_none_when_no_match(self) -> None: + oom_kills = [ + { + "pid": 9999, + "process_name": "other", + "total_vm_kb": None, + "rss_kb": None, + "line": "Killed process 9999 (other)", + } + ] + with patch( + "ccbot.process_info.check_recent_oom_kills", + new_callable=AsyncMock, + return_value=oom_kills, + ): + result = await was_pid_oom_killed(1234, descendants=[5678]) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_oom_kills(self) -> None: + with patch( + "ccbot.process_info.check_recent_oom_kills", + new_callable=AsyncMock, + return_value=[], + ): + result = await was_pid_oom_killed(1234) + assert result is None From 2d431bb51afa6a7b623c5f147a260e28d8b125c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 01:19:36 +0000 Subject: [PATCH 25/57] Add session death context to crash notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a tmux window dies, the notification now includes what Claude was doing at the time — extracted from the tail of the session JSONL file. Shows pending/running tools, last completed tool, and last assistant message (truncated to 200 chars). Example notification: ⚠️ Session killed by OOM killer (process: node, RSS: 14500MB): refinery Last activity: • Running: Bash(`npm run test:e2e`) • Last message: "All 4 test agents running in parallel..." https://claude.ai/code/session_01WHUN1GLBFr2ZkuEmeVtuPW --- src/ccbot/handlers/status_polling.py | 18 ++- src/ccbot/session.py | 84 ++++++++++++ tests/ccbot/test_session.py | 198 ++++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 7fb264d2..7748017b 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -223,15 +223,31 @@ async def status_poll_loop(bot: Bot) -> None: oom_info.get("line", ""), ) + # Extract last-activity context from JSONL + death_context = "" + try: + death_context = ( + await session_manager.get_session_death_context(wid) + ) + except Exception as e: + logger.debug( + "Failed to get death context for %s: %s", + wid, + e, + ) + # Notify user in the topic try: chat_id = session_manager.resolve_chat_id( user_id, thread_id ) + msg = f"\u26a0\ufe0f {reason}: {display_name}" + if death_context: + msg += f"\n\n{death_context}" await safe_send( bot, chat_id, - f"\u26a0\ufe0f {reason}: {display_name}", + msg, message_thread_id=thread_id, ) except Exception as e: diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 22ca2464..802820f0 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -920,5 +920,89 @@ async def get_recent_messages( return all_messages, len(all_messages) + async def get_session_death_context( + self, window_id: str, max_chars: int = 500 + ) -> str: + """Extract last-activity context from a session's JSONL for crash diagnostics. + + Reads the tail of the JSONL file and returns a formatted summary of + what Claude was doing when the session died (last tools, pending + operations, last message text). + + Returns empty string if session or file is unavailable. + """ + session = await self.resolve_session_for_window(window_id) + if not session or not session.file_path: + return "" + + file_path = Path(session.file_path) + if not file_path.exists(): + return "" + + # Read last ~8KB of the JSONL file (efficient tail) + try: + file_size = file_path.stat().st_size + tail_offset = max(0, file_size - 8192) + + entries: list[dict] = [] + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + if tail_offset > 0: + await f.seek(tail_offset) + await f.readline() # skip partial first line + + while True: + line = await f.readline() + if not line: + break + data = TranscriptParser.parse_line(line) + if data: + entries.append(data) + except OSError as e: + logger.debug("Error reading session file for death context: %s", e) + return "" + + if not entries: + return "" + + parsed_entries, remaining_pending = TranscriptParser.parse_entries(entries) + if not parsed_entries: + return "" + + lines: list[str] = [] + + # Pending tools (were mid-execution when session died) + if remaining_pending: + for tool_id, info in remaining_pending.items(): + lines.append(f"\u2022 Running: {info.summary}") + + # Last tool_use entries (most recent first) + tool_entries = [e for e in parsed_entries if e.content_type == "tool_use"] + if tool_entries and not remaining_pending: + last_tool = tool_entries[-1] + lines.append(f"\u2022 Last tool: {last_tool.text}") + + # Last assistant text message + text_entries = [ + e + for e in parsed_entries + if e.content_type == "text" and e.role == "assistant" + ] + if text_entries: + last_text = text_entries[-1].text.strip() + if last_text: + # Truncate long messages + if len(last_text) > 200: + last_text = last_text[:200] + "..." + lines.append(f'\u2022 Last message: "{last_text}"') + + if not lines: + return "" + + result = "Last activity:\n" + "\n".join(lines) + # Enforce overall length limit + if len(result) > max_chars: + result = result[:max_chars] + "..." + return result + session_manager = SessionManager() diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py index 96cfa4a7..0b1dad74 100644 --- a/tests/ccbot/test_session.py +++ b/tests/ccbot/test_session.py @@ -1,8 +1,10 @@ """Tests for SessionManager pure dict operations.""" +import json + import pytest -from ccbot.session import SessionManager +from ccbot.session import ClaudeSession, SessionManager @pytest.fixture @@ -186,3 +188,197 @@ 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 + + +def _make_jsonl_entry( + msg_type: str, + content: list, + session_id: str = "test-session", +) -> str: + """Build a JSONL line for testing.""" + return json.dumps( + { + "type": msg_type, + "timestamp": "2026-03-07T20:00:00.000Z", + "sessionId": session_id, + "cwd": "/tmp/test", + "message": {"content": content}, + } + ) + + +class TestGetSessionDeathContext: + """Tests for get_session_death_context — crash diagnostics from JSONL.""" + + @pytest.mark.asyncio + async def test_returns_last_tool_use(self, mgr: SessionManager, tmp_path) -> None: + """Shows the last tool that was running when session died.""" + jsonl = tmp_path / "session.jsonl" + lines = [ + _make_jsonl_entry( + "assistant", + [{"type": "text", "text": "Let me read the file."}], + ), + _make_jsonl_entry( + "assistant", + [ + { + "type": "tool_use", + "id": "tool_1", + "name": "Bash", + "input": {"command": "npm run test:e2e"}, + } + ], + ), + # No tool_result — tool was mid-execution when session died + ] + jsonl.write_text("\n".join(lines) + "\n") + + # Mock resolve_session_for_window to return our test file + async def mock_resolve(wid): + return ClaudeSession( + session_id="test", + summary="", + message_count=2, + file_path=str(jsonl), + ) + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + + assert "Last activity:" in result + assert "Running:" in result + assert "Bash" in result + assert "npm run test:e2e" in result + + @pytest.mark.asyncio + async def test_returns_last_text_message( + self, mgr: SessionManager, tmp_path + ) -> None: + """Shows the last assistant text message.""" + jsonl = tmp_path / "session.jsonl" + lines = [ + _make_jsonl_entry( + "assistant", + [{"type": "text", "text": "All 4 test agents running."}], + ), + ] + jsonl.write_text("\n".join(lines) + "\n") + + async def mock_resolve(wid): + return ClaudeSession( + session_id="test", + summary="", + message_count=1, + file_path=str(jsonl), + ) + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + + assert "Last message:" in result + assert "All 4 test agents running." in result + + @pytest.mark.asyncio + async def test_returns_empty_for_missing_session(self, mgr: SessionManager) -> None: + """Returns empty string when session can't be resolved.""" + + async def mock_resolve(wid): + return None + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + assert result == "" + + @pytest.mark.asyncio + async def test_returns_empty_for_missing_file(self, mgr: SessionManager) -> None: + """Returns empty string when JSONL file doesn't exist.""" + + async def mock_resolve(wid): + return ClaudeSession( + session_id="test", + summary="", + message_count=0, + file_path="/nonexistent/path.jsonl", + ) + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + assert result == "" + + @pytest.mark.asyncio + async def test_truncates_long_messages(self, mgr: SessionManager, tmp_path) -> None: + """Long assistant text is truncated to ~200 chars.""" + jsonl = tmp_path / "session.jsonl" + long_text = "x" * 500 + lines = [ + _make_jsonl_entry( + "assistant", + [{"type": "text", "text": long_text}], + ), + ] + jsonl.write_text("\n".join(lines) + "\n") + + async def mock_resolve(wid): + return ClaudeSession( + session_id="test", + summary="", + message_count=1, + file_path=str(jsonl), + ) + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + + assert "..." in result + # The truncated text should be at most 200 chars + "..." + for line in result.split("\n"): + if "Last message:" in line: + # Extract the quoted message content + msg_part = line.split('"')[1] if '"' in line else "" + assert len(msg_part) <= 204 # 200 + "..." + + @pytest.mark.asyncio + async def test_completed_tool_shows_last_tool( + self, mgr: SessionManager, tmp_path + ) -> None: + """When tool completed (has result), shows as 'Last tool' not 'Running'.""" + jsonl = tmp_path / "session.jsonl" + lines = [ + _make_jsonl_entry( + "assistant", + [ + { + "type": "tool_use", + "id": "tool_1", + "name": "Read", + "input": {"file_path": "src/main.py"}, + } + ], + ), + _make_jsonl_entry( + "user", + [ + { + "type": "tool_result", + "tool_use_id": "tool_1", + "content": "file contents here", + } + ], + ), + ] + jsonl.write_text("\n".join(lines) + "\n") + + async def mock_resolve(wid): + return ClaudeSession( + session_id="test", + summary="", + message_count=2, + file_path=str(jsonl), + ) + + mgr.resolve_session_for_window = mock_resolve # type: ignore[assignment] + result = await mgr.get_session_death_context("@1") + + assert "Last tool:" in result + assert "Running:" not in result From 1014a2b131efb65e64152729bdfdeb93fc2b5744 Mon Sep 17 00:00:00 2001 From: JanusMarko <96794314+JanusMarko@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:41:04 -0400 Subject: [PATCH 26/57] Trying to deal with OOM issues still MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary of Changes src/ccbot/process_info.py - Added get_mem_available_mb() — reads /proc/meminfo for MemAvailable, returns MB as float (None on non-Linux) src/ccbot/config.py - Memory monitoring now on by default (opt-out with CCBOT_MEMORY_MONITOR=false) - Lowered memory_check_interval from 30s → 10s - Added 3 new threshold configs: - CCBOT_MEM_AVAIL_WARN_MB (1024) — triggers notification - CCBOT_MEM_AVAIL_INTERRUPT_MB (512) — sends Escape to heaviest session - CCBOT_MEM_AVAIL_KILL_MB (256) — kills heaviest session src/ccbot/handlers/status_polling.py - Added _find_highest_rss_window() — finds the window consuming the most memory - Added _check_system_memory() — escalating system memory response: - Level 1 (warn): Notifies all topics that system memory is low - Level 2 (interrupt): Sends Escape to the heaviest window - Level 3 (kill): Kills the heaviest window, cleans up bindings - Safety mechanisms: - One level per check cycle (can't jump from OK to kill) - Cooldowns: 2 cycles (~20s) after interrupt before kill, 3 cycles (~30s) between kills - Hysteresis: resets to normal when memory recovers above warn × 1.5 - Downgrade: level drops when pressure partially relieves - No-target fallback: resets state if there are no windows to act on - Updated module docstring Tests - tests/ccbot/test_process_info.py — Added TestGetMemAvailableMb (3 tests) - tests/ccbot/handlers/test_system_memory.py — New file with 11 tests covering: normal operation, warn, one-level-per-cycle, interrupt escalation, cooldowns, kill + cleanup, hysteresis, partial pressure relief downgrade, disabled monitor, non-Linux fallback, highest-RSS finde --- .claude/settings.local.json | 13 + src/ccbot/config.py | 18 +- src/ccbot/handlers/status_polling.py | 222 +++++++++++++- src/ccbot/process_info.py | 22 ++ tests/ccbot/handlers/test_system_memory.py | 323 +++++++++++++++++++++ tests/ccbot/test_process_info.py | 40 +++ 6 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 tests/ccbot/handlers/test_system_memory.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..eba3817c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(cmd.exe:*)", + "Bash(pip show:*)", + "Bash(python3:*)", + "Bash(\"/c/Users/krisd/AppData/Local/Programs/Python/Python314/python.exe\":*)", + "Bash(uv run:*)", + "Bash(~/.local/bin/uv run:*)", + "Bash(ls:*)" + ] + } +} diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 841a4f7d..fb0a1271 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -96,13 +96,25 @@ def __init__(self) -> None: # Starting directory for the directory browser self.browse_root = os.getenv("CCBOT_BROWSE_ROOT", "") - # Memory monitoring (opt-in) + # Memory monitoring (on by default, opt-out with CCBOT_MEMORY_MONITOR=false) self.memory_monitor_enabled = ( - os.getenv("CCBOT_MEMORY_MONITOR", "").lower() == "true" + os.getenv("CCBOT_MEMORY_MONITOR", "true").lower() != "false" ) self.memory_warning_mb = float(os.getenv("CCBOT_MEMORY_WARNING_MB", "2048")) self.memory_check_interval = float( - os.getenv("CCBOT_MEMORY_CHECK_INTERVAL", "30") + os.getenv("CCBOT_MEMORY_CHECK_INTERVAL", "10") + ) + + # System-wide memory pressure thresholds (MemAvailable from /proc/meminfo) + # Escalation: warn → interrupt (send Escape) → kill (highest-RSS window) + self.mem_avail_warn_mb = float( + os.getenv("CCBOT_MEM_AVAIL_WARN_MB", "1024") + ) + self.mem_avail_interrupt_mb = float( + os.getenv("CCBOT_MEM_AVAIL_INTERRUPT_MB", "512") + ) + self.mem_avail_kill_mb = float( + os.getenv("CCBOT_MEM_AVAIL_KILL_MB", "256") ) # Scrub sensitive vars from os.environ so child processes never inherit them. diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index 7748017b..4f2faa3e 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -8,12 +8,15 @@ - Periodically probes topic existence via send_chat_action (TYPING); raises BadRequest on deleted topics; cleans up deleted topics (kills tmux window + unbinds thread) + - Proactive OOM prevention: system-wide MemAvailable monitoring with + escalating actions (warn → interrupt → kill highest-RSS window) Key components: - STATUS_POLL_INTERVAL: Polling frequency (1 second) - TOPIC_CHECK_INTERVAL: Topic existence probe frequency (60 seconds) - status_poll_loop: Background polling task - update_status_message: Poll and enqueue status updates + - _check_system_memory: Escalating system memory pressure response """ import asyncio @@ -25,7 +28,12 @@ from telegram.error import BadRequest from ..config import config -from ..process_info import get_process_tree_pids, get_tree_rss_mb, was_pid_oom_killed +from ..process_info import ( + get_mem_available_mb, + get_process_tree_pids, + get_tree_rss_mb, + was_pid_oom_killed, +) from ..session import session_manager from ..terminal_parser import is_interactive_ui, parse_status_line from ..tmux_manager import tmux_manager @@ -44,10 +52,16 @@ # Track pane PIDs so we can check OOM after window death _window_pids: dict[str, int] = {} # window_id → shell PID -# Memory monitoring state +# Per-window memory monitoring state _last_memory_check: float = 0.0 _memory_warned: set[str] = set() # window_ids that have been warned +# System-wide memory pressure escalation state +# Levels: 0=ok, 1=warned, 2=interrupted, 3=killed +_sys_mem_level: int = 0 +_sys_mem_cycles_at_level: int = 0 # how many check cycles at current level +_last_sys_mem_check: float = 0.0 + # Status polling interval STATUS_POLL_INTERVAL = 1.0 # seconds - faster response (rate limiting at send layer) @@ -291,8 +305,9 @@ async def status_poll_loop(bot: Bot) -> None: f"thread {thread_id}: {e}" ) - # Periodic memory monitoring (opt-in) + # Periodic memory monitoring await _check_memory_usage(bot) + await _check_system_memory(bot) except Exception as e: logger.error(f"Status poll loop error: {e}") @@ -342,3 +357,204 @@ async def _check_memory_usage(bot: Bot) -> None: _memory_warned.discard(wid) except Exception as e: logger.debug("Memory check error for window %s: %s", wid, e) + + +async def _find_highest_rss_window() -> ( + tuple[str, int, int, float] | None +): + """Find the window with the highest process tree RSS. + + Returns (window_id, user_id, thread_id, rss_mb) or None. + """ + highest: tuple[str, int, int, float] | None = None + for user_id, thread_id, wid in session_manager.all_thread_bindings(): + pid = _window_pids.get(wid) + if not pid: + continue + try: + rss_mb = await get_tree_rss_mb(pid) + if rss_mb is not None and (highest is None or rss_mb > highest[3]): + highest = (wid, user_id, thread_id, rss_mb) + except Exception: + continue + return highest + + +# Cooldown constants (in check cycles, not seconds — cycle = memory_check_interval) +# With default CCBOT_MEMORY_CHECK_INTERVAL=10: 2 cycles ≈ 20s, 3 cycles ≈ 30s +_INTERRUPT_COOLDOWN_CYCLES = 2 # wait 2 cycles after interrupt before kill +_KILL_COOLDOWN_CYCLES = 3 # wait 3 cycles after kill before another kill + + +async def _check_system_memory(bot: Bot) -> None: + """Check system-wide MemAvailable and escalate if memory is critically low. + + Escalation levels (advances at most one level per check cycle): + 0 → normal + 1 → warn (notify all topics) + 2 → interrupt (send Escape to highest-RSS window) + 3 → kill (kill highest-RSS window) + """ + global _sys_mem_level, _sys_mem_cycles_at_level, _last_sys_mem_check + + if not config.memory_monitor_enabled: + return + + now = time.monotonic() + if now - _last_sys_mem_check < config.memory_check_interval: + return + _last_sys_mem_check = now + + available = await get_mem_available_mb() + if available is None: + return # /proc/meminfo not readable (non-Linux) + + # Determine target level based on available memory + if available <= config.mem_avail_kill_mb: + target_level = 3 + elif available <= config.mem_avail_interrupt_mb: + target_level = 2 + elif available <= config.mem_avail_warn_mb: + target_level = 1 + else: + target_level = 0 + + # Recovery: reset when memory is well above warn threshold (hysteresis) + if available > config.mem_avail_warn_mb * 1.5: + if _sys_mem_level > 0: + logger.info( + "System memory recovered: %.0fMB available, resetting escalation", + available, + ) + _sys_mem_level = 0 + _sys_mem_cycles_at_level = 0 + return + + # No escalation needed + if target_level == 0: + _sys_mem_level = 0 + _sys_mem_cycles_at_level = 0 + return + + # Track cycles at current level + _sys_mem_cycles_at_level += 1 + + # Advance at most one level per cycle + new_level = min(target_level, _sys_mem_level + 1) + + # Enforce cooldowns before escalating + if new_level == 3 and _sys_mem_level == 2: + if _sys_mem_cycles_at_level < _INTERRUPT_COOLDOWN_CYCLES: + logger.debug( + "Interrupt cooldown: %d/%d cycles", + _sys_mem_cycles_at_level, + _INTERRUPT_COOLDOWN_CYCLES, + ) + return + if new_level == 3 and _sys_mem_level == 3: + if _sys_mem_cycles_at_level < _KILL_COOLDOWN_CYCLES: + logger.debug( + "Kill cooldown: %d/%d cycles", + _sys_mem_cycles_at_level, + _KILL_COOLDOWN_CYCLES, + ) + return + + # Downgrade level when pressure has eased + if new_level < _sys_mem_level: + _sys_mem_level = new_level + _sys_mem_cycles_at_level = 0 + return + + # Only act when level actually changes (or kill repeats) + if new_level <= _sys_mem_level and new_level < 3: + return + + # Reset cycle counter on level change + if new_level != _sys_mem_level: + _sys_mem_cycles_at_level = 0 + _sys_mem_level = new_level + + # === Level 1: Warn all topics === + if new_level == 1: + logger.warning("System memory low: %.0fMB available", available) + for user_id, thread_id, _wid in session_manager.all_thread_bindings(): + try: + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send( + bot, + chat_id, + f"\u26a0\ufe0f System memory low: {available:.0f}MB available. " + "Consider reducing parallel workloads.", + message_thread_id=thread_id, + ) + except Exception as e: + logger.debug("Failed to send memory warning: %s", e) + + # === Level 2: Interrupt highest-RSS window === + elif new_level == 2: + target = await _find_highest_rss_window() + if not target: + logger.warning( + "Memory critical (%.0fMB available) but no windows to interrupt", + available, + ) + return + wid, user_id, thread_id, rss_mb = target + display_name = session_manager.get_display_name(wid) + logger.warning( + "Memory critical (%.0fMB available) — interrupting %s (%.0fMB RSS)", + available, + display_name, + rss_mb, + ) + await tmux_manager.send_keys(wid, "Escape", enter=False, literal=False) + try: + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send( + bot, + chat_id, + f"\u26a0\ufe0f Memory critical ({available:.0f}MB available) " + f"\u2014 interrupted {display_name} ({rss_mb:.0f}MB RSS)", + message_thread_id=thread_id, + ) + except Exception as e: + logger.debug("Failed to send interrupt notification: %s", e) + + # === Level 3: Kill highest-RSS window === + elif new_level == 3: + target = await _find_highest_rss_window() + if not target: + logger.warning( + "Memory emergency (%.0fMB available) but no windows to kill", + available, + ) + _sys_mem_level = 0 + _sys_mem_cycles_at_level = 0 + return + wid, user_id, thread_id, rss_mb = target + display_name = session_manager.get_display_name(wid) + logger.error( + "Memory emergency (%.0fMB available) — killing %s (%.0fMB RSS)", + available, + display_name, + rss_mb, + ) + await tmux_manager.kill_window(wid) + _window_pids.pop(wid, None) + _memory_warned.discard(wid) + try: + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + await safe_send( + bot, + chat_id, + f"\U0001f6a8 Memory emergency ({available:.0f}MB available) " + f"\u2014 killed {display_name} ({rss_mb:.0f}MB RSS) " + "to prevent system OOM", + message_thread_id=thread_id, + ) + except Exception as e: + logger.debug("Failed to send kill notification: %s", e) + session_manager.unbind_thread(user_id, thread_id) + await clear_topic_state(user_id, thread_id, bot) + _sys_mem_cycles_at_level = 0 # reset for next kill cooldown diff --git a/src/ccbot/process_info.py b/src/ccbot/process_info.py index 750db2a7..6b3cc1e0 100644 --- a/src/ccbot/process_info.py +++ b/src/ccbot/process_info.py @@ -140,6 +140,28 @@ async def check_recent_oom_kills() -> list[dict[str, object]]: return [] +async def get_mem_available_mb() -> float | None: + """Read MemAvailable from /proc/meminfo. Returns MB or None if unavailable. + + MemAvailable is the kernel's estimate of memory available for new + allocations without swapping — more accurate than MemFree alone. + """ + meminfo = Path("/proc/meminfo") + try: + if not meminfo.exists(): + return None + text = await asyncio.to_thread(meminfo.read_text) + for line in text.splitlines(): + if line.startswith("MemAvailable:"): + # Format: "MemAvailable: 12345678 kB" + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) / 1024.0 + except (OSError, ValueError): + pass + return None + + async def was_pid_oom_killed( shell_pid: int, descendants: list[int] | None = None, diff --git a/tests/ccbot/handlers/test_system_memory.py b/tests/ccbot/handlers/test_system_memory.py new file mode 100644 index 00000000..4dac96af --- /dev/null +++ b/tests/ccbot/handlers/test_system_memory.py @@ -0,0 +1,323 @@ +"""Tests for system-wide memory pressure detection and escalation. + +Verifies the warn → interrupt → kill escalation in _check_system_memory: + - Warn sends notifications to all bound topics + - Interrupt sends Escape to highest-RSS window + - Kill removes highest-RSS window and cleans up bindings + - Escalation advances at most one level per check cycle + - Cooldowns prevent rapid successive kills + - Hysteresis resets escalation when memory recovers +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers import status_polling +from ccbot.handlers.status_polling import ( + _check_system_memory, + _find_highest_rss_window, +) + + +@pytest.fixture(autouse=True) +def _reset_escalation_state(): + """Reset module-level escalation state before and after each test.""" + status_polling._sys_mem_level = 0 + status_polling._sys_mem_cycles_at_level = 0 + status_polling._last_sys_mem_check = 0.0 + status_polling._window_pids.clear() + status_polling._memory_warned.clear() + yield + status_polling._sys_mem_level = 0 + status_polling._sys_mem_cycles_at_level = 0 + status_polling._last_sys_mem_check = 0.0 + status_polling._window_pids.clear() + status_polling._memory_warned.clear() + + +@pytest.fixture +def mock_bot(): + return AsyncMock() + + +@pytest.fixture +def mock_config(): + """Patch config with test thresholds.""" + with patch("ccbot.handlers.status_polling.config") as cfg: + cfg.memory_monitor_enabled = True + cfg.memory_check_interval = 0 # no throttle in tests + cfg.mem_avail_warn_mb = 1024.0 + cfg.mem_avail_interrupt_mb = 512.0 + cfg.mem_avail_kill_mb = 256.0 + yield cfg + + +@pytest.fixture +def one_binding(): + """Set up one thread binding with a tracked PID.""" + status_polling._window_pids["@0"] = 1234 + with ( + patch( + "ccbot.handlers.status_polling.session_manager" + ) as mock_sm, + patch("ccbot.handlers.status_polling.tmux_manager") as mock_tmux, + patch( + "ccbot.handlers.status_polling.safe_send", + new_callable=AsyncMock, + ) as mock_send, + patch( + "ccbot.handlers.status_polling.clear_topic_state", + new_callable=AsyncMock, + ), + ): + mock_sm.all_thread_bindings.return_value = [(100, 42, "@0")] + mock_sm.resolve_chat_id.return_value = 100 + mock_sm.get_display_name.return_value = "test-session" + mock_tmux.send_keys = AsyncMock(return_value=True) + mock_tmux.kill_window = AsyncMock(return_value=True) + yield { + "session_manager": mock_sm, + "tmux_manager": mock_tmux, + "safe_send": mock_send, + } + + +class TestCheckSystemMemory: + """Test _check_system_memory escalation logic.""" + + @pytest.mark.asyncio + async def test_no_action_when_memory_ok( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """MemAvailable > warn threshold → no notification.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=4000.0, + ): + await _check_system_memory(mock_bot) + one_binding["safe_send"].assert_not_called() + assert status_polling._sys_mem_level == 0 + + @pytest.mark.asyncio + async def test_warn_on_low_memory( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """MemAvailable <= warn threshold → warn notification to all topics.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=800.0, + ): + await _check_system_memory(mock_bot) + one_binding["safe_send"].assert_called_once() + msg = one_binding["safe_send"].call_args[0][2] + assert "System memory low" in msg + assert "800" in msg + assert status_polling._sys_mem_level == 1 + + @pytest.mark.asyncio + async def test_one_level_per_cycle( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """Even with kill-level pressure, first cycle only warns.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=100.0, # below kill threshold + ), patch( + "ccbot.handlers.status_polling.get_tree_rss_mb", + new_callable=AsyncMock, + return_value=3000.0, + ): + await _check_system_memory(mock_bot) + # Should only be at warn level, not kill + assert status_polling._sys_mem_level == 1 + msg = one_binding["safe_send"].call_args[0][2] + assert "System memory low" in msg + + @pytest.mark.asyncio + async def test_escalates_to_interrupt( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """Warn → interrupt on continued pressure.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=400.0, + ), patch( + "ccbot.handlers.status_polling.get_tree_rss_mb", + new_callable=AsyncMock, + return_value=3000.0, + ): + # Cycle 1: warn + await _check_system_memory(mock_bot) + assert status_polling._sys_mem_level == 1 + + # Cycle 2: interrupt + status_polling._last_sys_mem_check = 0.0 # bypass throttle + await _check_system_memory(mock_bot) + assert status_polling._sys_mem_level == 2 + one_binding["tmux_manager"].send_keys.assert_called_once_with( + "@0", "Escape", enter=False, literal=False + ) + + @pytest.mark.asyncio + async def test_interrupt_cooldown_before_kill( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """After interrupt, must wait cooldown cycles before kill.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=200.0, + ), patch( + "ccbot.handlers.status_polling.get_tree_rss_mb", + new_callable=AsyncMock, + return_value=3000.0, + ): + # Cycle 1: warn + await _check_system_memory(mock_bot) + assert status_polling._sys_mem_level == 1 + + # Cycle 2: interrupt + status_polling._last_sys_mem_check = 0.0 + await _check_system_memory(mock_bot) + assert status_polling._sys_mem_level == 2 + + # Cycle 3: cooldown (should NOT kill yet) + status_polling._last_sys_mem_check = 0.0 + await _check_system_memory(mock_bot) + one_binding["tmux_manager"].kill_window.assert_not_called() + + # Cycle 4: cooldown satisfied → kill + status_polling._last_sys_mem_check = 0.0 + await _check_system_memory(mock_bot) + one_binding["tmux_manager"].kill_window.assert_called_once_with("@0") + assert status_polling._sys_mem_level == 3 + + @pytest.mark.asyncio + async def test_kill_cleans_up_bindings( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """Kill action unbinds thread and clears topic state.""" + # Fast-track to kill level + status_polling._sys_mem_level = 2 + status_polling._sys_mem_cycles_at_level = 10 # past cooldown + + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=200.0, + ), patch( + "ccbot.handlers.status_polling.get_tree_rss_mb", + new_callable=AsyncMock, + return_value=3000.0, + ): + await _check_system_memory(mock_bot) + + one_binding["tmux_manager"].kill_window.assert_called_once_with("@0") + one_binding["session_manager"].unbind_thread.assert_called_once_with(100, 42) + assert "@0" not in status_polling._window_pids + + @pytest.mark.asyncio + async def test_hysteresis_reset( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """Memory recovery above warn*1.5 resets escalation to level 0.""" + status_polling._sys_mem_level = 2 # was at interrupt + + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=2000.0, # well above 1024 * 1.5 = 1536 + ): + await _check_system_memory(mock_bot) + + assert status_polling._sys_mem_level == 0 + assert status_polling._sys_mem_cycles_at_level == 0 + + @pytest.mark.asyncio + async def test_level_downgrade_when_pressure_partially_relieves( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """If at kill level but memory rises to interrupt level, level downgrades.""" + status_polling._sys_mem_level = 3 + status_polling._sys_mem_cycles_at_level = 0 + + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=400.0, # interrupt level, not kill + ): + await _check_system_memory(mock_bot) + + assert status_polling._sys_mem_level == 2 + assert status_polling._sys_mem_cycles_at_level == 0 + + @pytest.mark.asyncio + async def test_disabled_monitor_skips( + self, mock_bot: AsyncMock, mock_config: MagicMock + ) -> None: + """When memory_monitor_enabled=False, no action taken.""" + mock_config.memory_monitor_enabled = False + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + ) as mock_mem: + await _check_system_memory(mock_bot) + mock_mem.assert_not_called() + + @pytest.mark.asyncio + async def test_none_mem_available_skips( + self, mock_bot: AsyncMock, mock_config: MagicMock, one_binding: dict + ) -> None: + """Non-Linux (MemAvailable=None) → no action.""" + with patch( + "ccbot.handlers.status_polling.get_mem_available_mb", + new_callable=AsyncMock, + return_value=None, + ): + await _check_system_memory(mock_bot) + one_binding["safe_send"].assert_not_called() + + +class TestFindHighestRssWindow: + """Test _find_highest_rss_window helper.""" + + @pytest.mark.asyncio + async def test_finds_highest_rss(self) -> None: + status_polling._window_pids = {"@0": 100, "@1": 200} + rss_map = {100: 1500.0, 200: 3000.0} + + with ( + patch( + "ccbot.handlers.status_polling.session_manager" + ) as mock_sm, + patch( + "ccbot.handlers.status_polling.get_tree_rss_mb", + new_callable=AsyncMock, + side_effect=lambda pid: rss_map.get(pid), + ), + ): + mock_sm.all_thread_bindings.return_value = [ + (1, 10, "@0"), + (1, 20, "@1"), + ] + result = await _find_highest_rss_window() + + assert result is not None + wid, user_id, thread_id, rss_mb = result + assert wid == "@1" + assert rss_mb == 3000.0 + + @pytest.mark.asyncio + async def test_returns_none_when_no_pids(self) -> None: + status_polling._window_pids = {} + with patch( + "ccbot.handlers.status_polling.session_manager" + ) as mock_sm: + mock_sm.all_thread_bindings.return_value = [(1, 10, "@0")] + result = await _find_highest_rss_window() + assert result is None diff --git a/tests/ccbot/test_process_info.py b/tests/ccbot/test_process_info.py index e637730b..18ddb826 100644 --- a/tests/ccbot/test_process_info.py +++ b/tests/ccbot/test_process_info.py @@ -6,6 +6,7 @@ from ccbot.process_info import ( _parse_dmesg_for_oom_kills, + get_mem_available_mb, get_process_rss_mb, was_pid_oom_killed, ) @@ -154,3 +155,42 @@ async def test_returns_none_when_no_oom_kills(self) -> None: ): result = await was_pid_oom_killed(1234) assert result is None + + +class TestGetMemAvailableMb: + """Test reading MemAvailable from /proc/meminfo.""" + + @pytest.mark.asyncio + async def test_parses_standard_meminfo(self) -> None: + meminfo_content = ( + "MemTotal: 16384000 kB\n" + "MemFree: 2048000 kB\n" + "MemAvailable: 8192000 kB\n" + "Buffers: 512000 kB\n" + ) + with patch("ccbot.process_info.Path") as mock_path: + mock_file = mock_path.return_value + mock_file.exists.return_value = True + mock_file.read_text.return_value = meminfo_content + result = await get_mem_available_mb() + assert result is not None + assert abs(result - 8000.0) < 0.1 # 8192000 kB = 8000 MB + + @pytest.mark.asyncio + async def test_returns_none_when_file_missing(self) -> None: + with patch("ccbot.process_info.Path") as mock_path: + mock_file = mock_path.return_value + mock_file.exists.return_value = False + result = await get_mem_available_mb() + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_malformed_input(self) -> None: + meminfo_content = "MemTotal: 16384000 kB\nMemFree: garbage\n" + with patch("ccbot.process_info.Path") as mock_path: + mock_file = mock_path.return_value + mock_file.exists.return_value = True + mock_file.read_text.return_value = meminfo_content + result = await get_mem_available_mb() + # MemAvailable line is absent entirely → None + assert result is None From 7fdae1dacd4c1a8ea843ab61c751c336667b820d Mon Sep 17 00:00:00 2001 From: Kris Doane <96794314+JanusMarko@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:28:20 -0400 Subject: [PATCH 27/57] Add Telegram document upload and message type emojis (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add document upload handler for text/markdown files Save uploaded text-based files (Markdown, code, config, etc.) to {session_cwd}/docs/inbox/ and forward the file path to Claude Code. Follows the same pattern as the existing photo_handler. https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz * Support PDF and Word document uploads in document handler - PDFs: saved directly to docs/inbox/ (Claude Code reads them natively) - Word docs (.docx/.doc): converted to Markdown via python-docx, saved as .md - Added python-docx dependency - Updated MIME type and extension allowlists https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz * Put file notification before user caption in document handler Claude Code needs to know the file exists before processing the user's instruction. Reorder so the file path/read hint comes first, followed by the user's caption text. https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz * Add speech balloon emoji prefix to assistant text messages Prefixes human-readable assistant messages with 💬 so they're visually distinct from tool_use/tool_result messages in Telegram. https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz * Use colorful square emojis for all message type prefixes - 🟦 User messages - 🟩 Assistant text - 🟧 Tool use / tool result - 🟪 Thinking https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz * Use colorful emojis for message type prefixes - 💎 User messages - 🔮 Assistant text - 🛠️ Tool use / tool result - 🧠 Thinking https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz --------- Co-authored-by: Claude --- pyproject.toml | 1 + src/ccbot/bot.py | 221 +++++++++++++++++++++++++ src/ccbot/handlers/response_builder.py | 15 +- 3 files changed, 233 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f02ba25c..81e6d87b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "Pillow>=10.0.0", "aiofiles>=24.0.0", "telegramify-markdown>=0.5.0", + "python-docx>=1.0.0", ] [project.scripts] diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index ff782aa6..5a25d20b 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -12,6 +12,8 @@ Unbound topics trigger the directory browser to create a new session. - Photo handling: photos sent by user are downloaded and forwarded to Claude Code as file paths (photo_handler). + - Document handling: Markdown and text files sent by user are saved to + {session_cwd}/docs/inbox/ and path forwarded to Claude Code (document_handler). - Automatic cleanup: closing a topic kills the associated window (topic_closed_handler). Unsupported content (stickers, voice, etc.) is rejected with a warning (unsupported_content_handler). @@ -683,6 +685,223 @@ async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_reply(update.message, "📷 Image sent to Claude Code.") +# --- Allowed document MIME types for upload --- +_ALLOWED_DOC_MIME_PREFIXES = ("text/",) +_ALLOWED_DOC_MIME_TYPES = { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", +} +_ALLOWED_DOC_EXTENSIONS = { + ".md", + ".markdown", + ".txt", + ".csv", + ".json", + ".yaml", + ".yml", + ".toml", + ".xml", + ".html", + ".css", + ".js", + ".ts", + ".py", + ".sh", + ".bash", + ".rs", + ".go", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".rb", + ".pl", + ".lua", + ".sql", + ".r", + ".swift", + ".kt", + ".scala", + ".ex", + ".exs", + ".hs", + ".ml", + ".clj", + ".el", + ".vim", + ".conf", + ".ini", + ".cfg", + ".env", + ".log", + ".diff", + ".patch", + ".pdf", + ".docx", + ".doc", +} + + +def _convert_docx_to_markdown(docx_path: Path) -> str: + """Extract text from a .docx file and return as markdown.""" + import docx + + doc = docx.Document(str(docx_path)) + lines: list[str] = [] + for para in doc.paragraphs: + text = para.text + if not text.strip(): + lines.append("") + continue + style_name = (para.style.name or "").lower() if para.style else "" + if style_name.startswith("heading 1"): + lines.append(f"# {text}") + elif style_name.startswith("heading 2"): + lines.append(f"## {text}") + elif style_name.startswith("heading 3"): + lines.append(f"### {text}") + elif style_name.startswith("heading 4"): + lines.append(f"#### {text}") + elif style_name.startswith("list"): + lines.append(f"- {text}") + else: + lines.append(text) + return "\n\n".join(lines) + + +async def document_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle document uploads: save text/code/PDF/Word files to session cwd and forward path.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + if update.message: + await safe_reply(update.message, "You are not authorized to use this bot.") + return + + if not update.message or not update.message.document: + return + + doc = update.message.document + file_name = doc.file_name or "unnamed_document" + mime = doc.mime_type or "" + ext = Path(file_name).suffix.lower() + + # Check if file type is allowed + if ( + not any(mime.startswith(p) for p in _ALLOWED_DOC_MIME_PREFIXES) + and mime not in _ALLOWED_DOC_MIME_TYPES + and ext not in _ALLOWED_DOC_EXTENSIONS + ): + await safe_reply( + update.message, + f"⚠ Unsupported file type: {file_name}\n" + "Supported: text files, code, Markdown, PDF, and Word documents.", + ) + return + + chat = update.message.chat + thread_id = _get_thread_id(update) + if chat.type in ("group", "supergroup") and thread_id is not None: + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + # Must be in a named topic + if thread_id is None: + await safe_reply( + update.message, + "❌ Please use a named topic. Create a new topic to start a session.", + ) + return + + wid = session_manager.get_window_for_thread(user.id, thread_id) + if wid is None: + await safe_reply( + update.message, + "❌ No session bound to this topic. Send a text message first to create one.", + ) + return + + w = await tmux_manager.find_window_by_id(wid) + if not w: + display = session_manager.get_display_name(wid) + session_manager.unbind_thread(user.id, thread_id) + await safe_reply( + update.message, + f"❌ Window '{display}' no longer exists. Binding removed.\n" + "Send a message to start a new session.", + ) + return + + # Resolve session cwd for the inbox directory + ws = session_manager.get_window_state(wid) + if not ws.cwd: + await safe_reply( + update.message, + "❌ Session working directory not yet known. Try again in a moment.", + ) + return + + inbox_dir = Path(ws.cwd) / "docs" / "inbox" + inbox_dir.mkdir(parents=True, exist_ok=True) + + tg_file = await doc.get_file() + is_docx = ext in (".docx", ".doc") or mime in ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + ) + + if is_docx: + # Convert Word documents to Markdown + import tempfile + + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + await tg_file.download_to_drive(tmp_path) + md_content = await asyncio.to_thread(_convert_docx_to_markdown, tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + + save_name = Path(file_name).stem + ".md" + dest = inbox_dir / save_name + if dest.exists(): + dest = inbox_dir / f"{Path(file_name).stem}_{int(time.time())}.md" + dest.write_text(md_content, encoding="utf-8") + else: + # Save PDFs and text files directly + dest = inbox_dir / file_name + if dest.exists(): + stem = Path(file_name).stem + dest = inbox_dir / f"{stem}_{int(time.time())}{ext}" + await tg_file.download_to_drive(dest) + + # Build message for Claude Code — file context first, then user's instruction + rel_path = f"docs/inbox/{dest.name}" + caption = update.message.caption or "" + file_notice = ( + f"A file has been saved to {rel_path} (absolute path: {dest}). " + "Read it with your Read tool." + ) + if caption: + text_to_send = f"{file_notice}\n\n{caption}" + else: + text_to_send = file_notice + + await update.message.chat.send_action(ChatAction.TYPING) + clear_status_msg_info(user.id, thread_id) + + success, message = await session_manager.send_to_window(wid, text_to_send) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + suffix_note = " (converted from Word to Markdown)" if is_docx else "" + await safe_reply( + update.message, + f"📄 File saved to `{rel_path}`{suffix_note} and sent to Claude Code.", + ) + + # Active bash capture tasks: (user_id, thread_id) → asyncio.Task _bash_capture_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} @@ -1751,6 +1970,8 @@ def create_bot() -> Application: ) # Photos: download and forward file path to Claude Code application.add_handler(MessageHandler(filters.PHOTO, photo_handler)) + # Documents: save text/markdown files to session cwd and forward path + application.add_handler(MessageHandler(filters.Document.ALL, document_handler)) # Catch-all: non-text content (stickers, voice, etc.) application.add_handler( MessageHandler( diff --git a/src/ccbot/handlers/response_builder.py b/src/ccbot/handlers/response_builder.py index 41b7d0c9..7e7a1953 100644 --- a/src/ccbot/handlers/response_builder.py +++ b/src/ccbot/handlers/response_builder.py @@ -33,7 +33,7 @@ def build_response_parts( # User messages: add emoji prefix (no newline) if role == "user": - prefix = "👤 " + prefix = "💎 " separator = "" # User messages are typically short, no special processing needed if len(text) > 3000: @@ -55,11 +55,18 @@ def build_response_parts( # Format based on content type if content_type == "thinking": - # Thinking: prefix with "∴ Thinking…" and single newline - prefix = "∴ Thinking…" + # Thinking: purple prefix + prefix = "🧠 Thinking…" separator = "\n" + elif content_type in ("tool_use", "tool_result"): + # Tool calls: orange prefix + prefix = "🛠️" + separator = " " + elif content_type == "text": + # Assistant text: green prefix + prefix = "🔮" + separator = " " else: - # Plain text: no prefix prefix = "" separator = "" From ce8a01610e2d7b2d248fe0c88d6b9b27630ccca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 19:24:05 +0000 Subject: [PATCH 28/57] Add mistletoe as explicit dependency Fixes ModuleNotFoundError on fresh install. The module is imported directly in markdown_v2.py but was only a transitive dep. https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 81e6d87b..fe2c1ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "Pillow>=10.0.0", "aiofiles>=24.0.0", "telegramify-markdown>=0.5.0", + "mistletoe>=1.0.0", "python-docx>=1.0.0", ] From e834f6502ef107d65316a4fa111d718e86de584e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 19:26:37 +0000 Subject: [PATCH 29/57] Pin telegramify-markdown <1.0 to fix ImportError The 1.x release removed _update_block and renamed escape_latex, breaking the markdown_v2.py import. Pin to 0.5.x which has the API our code depends on. https://claude.ai/code/session_01Db4zZKSaAkJrgHGzeLjRsz --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fe2c1ef3..1f74dd9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "libtmux>=0.37.0", "Pillow>=10.0.0", "aiofiles>=24.0.0", - "telegramify-markdown>=0.5.0", + "telegramify-markdown>=0.5.0,<1.0", "mistletoe>=1.0.0", "python-docx>=1.0.0", ] From 63dfec252c7e26c34548f2de1291b2633b81057f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 23:13:55 +0000 Subject: [PATCH 30/57] Add ccweb: React web gateway to Claude Code (Phase 1 backend) New self-contained project in ccweb/ that replaces the Telegram interface with a browser-based web frontend for Claude Code sessions via tmux. Backend (Python/FastAPI): - Forked 7 core modules from ccbot (tmux_manager, terminal_parser, session_monitor, transcript_parser, monitor_state, hook, utils) with imports adapted for ccweb (ccweb_dir, separate ~/.ccweb/ state) - New config.py: web server settings, no Telegram dependencies - New session.py: simplified client_bindings model (no thread_bindings) - New server.py: FastAPI + WebSocket for real-time bidirectional comms - New ws_protocol.py: typed message definitions (message, interactive_ui, decision_grid, status, sessions, health, error, replay) - New ui_parser.py: parses raw terminal text into structured interactive UI data with fallback to raw text display - New main.py: CLI with ccweb (serve), ccweb install (hook + commands), ccweb hook (SessionStart handler) - Startup health checks: tmux running, hook installed, config valid - Stale UI guard on interactive prompts (re-captures pane before acting) Decision grid protocol: file-based detection (.ccweb/pending/*.json) with AskUserQuestion blocking to prevent timing issues. Docs: - Full design plan (docs/architecture/design-plan.md) - V2 roadmap (docs/architecture/v2-roadmap.md) https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw --- ccweb/backend/__init__.py | 1 + ccweb/backend/config.py | 125 ++++ ccweb/backend/core/__init__.py | 1 + ccweb/backend/core/hook.py | 225 +++++++ ccweb/backend/core/monitor_state.py | 109 +++ ccweb/backend/core/session_monitor.py | 525 +++++++++++++++ ccweb/backend/core/terminal_parser.py | 365 ++++++++++ ccweb/backend/core/tmux_manager.py | 477 ++++++++++++++ ccweb/backend/core/transcript_parser.py | 774 ++++++++++++++++++++++ ccweb/backend/core/utils.py | 69 ++ ccweb/backend/main.py | 197 ++++++ ccweb/backend/server.py | 471 +++++++++++++ ccweb/backend/session.py | 384 +++++++++++ ccweb/backend/ui_parser.py | 213 ++++++ ccweb/backend/ws_protocol.py | 135 ++++ ccweb/docs/architecture/design-plan.md | 843 ++++++++++++++++++++++++ ccweb/docs/architecture/v2-roadmap.md | 78 +++ ccweb/pyproject.toml | 43 ++ 18 files changed, 5035 insertions(+) create mode 100644 ccweb/backend/__init__.py create mode 100644 ccweb/backend/config.py create mode 100644 ccweb/backend/core/__init__.py create mode 100644 ccweb/backend/core/hook.py create mode 100644 ccweb/backend/core/monitor_state.py create mode 100644 ccweb/backend/core/session_monitor.py create mode 100644 ccweb/backend/core/terminal_parser.py create mode 100644 ccweb/backend/core/tmux_manager.py create mode 100644 ccweb/backend/core/transcript_parser.py create mode 100644 ccweb/backend/core/utils.py create mode 100644 ccweb/backend/main.py create mode 100644 ccweb/backend/server.py create mode 100644 ccweb/backend/session.py create mode 100644 ccweb/backend/ui_parser.py create mode 100644 ccweb/backend/ws_protocol.py create mode 100644 ccweb/docs/architecture/design-plan.md create mode 100644 ccweb/docs/architecture/v2-roadmap.md create mode 100644 ccweb/pyproject.toml diff --git a/ccweb/backend/__init__.py b/ccweb/backend/__init__.py new file mode 100644 index 00000000..7b0546a4 --- /dev/null +++ b/ccweb/backend/__init__.py @@ -0,0 +1 @@ +"""CCWeb backend — FastAPI + WebSocket gateway to Claude Code via tmux.""" diff --git a/ccweb/backend/config.py b/ccweb/backend/config.py new file mode 100644 index 00000000..5662789a --- /dev/null +++ b/ccweb/backend/config.py @@ -0,0 +1,125 @@ +"""Application configuration — reads env vars and exposes a singleton. + +Loads web server settings, tmux/Claude paths, and monitoring intervals +from environment variables (with .env support). +.env loading priority: local .env (cwd) > $CCWEB_DIR/.env (default ~/.ccweb). + +The module-level `config` instance is imported by nearly every other module. +Attribute names match those expected by forked ccbot core modules. + +Key class: Config (singleton instantiated as `config`). +""" + +import logging +import os +from pathlib import Path + +from dotenv import load_dotenv + +from .core.utils import ccweb_dir + +logger = logging.getLogger(__name__) + +# Env vars that must not leak to child processes (e.g. Claude Code via tmux) +SENSITIVE_ENV_VARS: set[str] = {"CCWEB_AUTH_TOKEN"} + + +class Config: + """Application configuration loaded from environment variables. + + Exposes the same attribute names as ccbot's Config so that forked + core modules (tmux_manager, session_monitor, etc.) work unchanged. + """ + + def __init__(self) -> None: + self.config_dir = ccweb_dir() + self.config_dir.mkdir(parents=True, exist_ok=True) + + # Load .env: local (cwd) takes priority over config_dir + local_env = Path(".env") + global_env = self.config_dir / ".env" + if local_env.is_file(): + load_dotenv(local_env) + logger.debug("Loaded env from %s", local_env.resolve()) + if global_env.is_file(): + load_dotenv(global_env) + logger.debug("Loaded env from %s", global_env) + + # --- Web server settings --- + self.web_host: str = os.getenv("CCWEB_HOST", "0.0.0.0") + self.web_port: int = int(os.getenv("CCWEB_PORT", "8765")) + self.web_auth_token: str = os.getenv("CCWEB_AUTH_TOKEN", "") + + # --- Tmux settings (same attr names as ccbot) --- + self.tmux_session_name: str = os.getenv("TMUX_SESSION_NAME", "ccbot") + self.tmux_main_window_name: str = "__main__" + + # Claude command to run in new windows + self.claude_command: str = os.getenv("CLAUDE_COMMAND", "claude") + + # --- State files (all under config_dir) --- + self.state_file: Path = self.config_dir / "state.json" + self.session_map_file: Path = self.config_dir / "session_map.json" + self.monitor_state_file: Path = self.config_dir / "monitor_state.json" + + # --- Claude Code session monitoring --- + custom_projects_path = os.getenv("CCWEB_CLAUDE_PROJECTS_PATH") + claude_config_dir = os.getenv("CLAUDE_CONFIG_DIR") + + if custom_projects_path: + self.claude_projects_path: Path = Path(custom_projects_path) + elif claude_config_dir: + self.claude_projects_path = Path(claude_config_dir) / "projects" + else: + self.claude_projects_path = Path.home() / ".claude" / "projects" + + self.monitor_poll_interval: float = float( + os.getenv("MONITOR_POLL_INTERVAL", "2.0") + ) + + # Display user messages in history and real-time notifications + self.show_user_messages: bool = True + + # Directory browser settings + self.show_hidden_dirs: bool = ( + os.getenv("CCWEB_SHOW_HIDDEN_DIRS", "").lower() == "true" + ) + self.browse_root: str = os.getenv("CCWEB_BROWSE_ROOT", "") + + # --- Memory monitoring --- + self.memory_monitor_enabled: bool = ( + os.getenv("CCWEB_MEMORY_MONITOR", "true").lower() != "false" + ) + self.memory_warning_mb: float = float( + os.getenv("CCWEB_MEMORY_WARNING_MB", "2048") + ) + self.memory_check_interval: float = float( + os.getenv("CCWEB_MEMORY_CHECK_INTERVAL", "10") + ) + self.mem_avail_warn_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_WARN_MB", "1024") + ) + self.mem_avail_interrupt_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_INTERRUPT_MB", "512") + ) + self.mem_avail_kill_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_KILL_MB", "256") + ) + + # Scrub sensitive vars from os.environ + for var in SENSITIVE_ENV_VARS: + os.environ.pop(var, None) + + logger.debug( + "Config initialized: dir=%s, host=%s, port=%d, " + "tmux_session=%s, claude_projects_path=%s", + self.config_dir, + self.web_host, + self.web_port, + self.tmux_session_name, + self.claude_projects_path, + ) + + +# Lazy singleton — instantiated on first import. Tests can replace this. +config = Config() diff --git a/ccweb/backend/core/__init__.py b/ccweb/backend/core/__init__.py new file mode 100644 index 00000000..1757889a --- /dev/null +++ b/ccweb/backend/core/__init__.py @@ -0,0 +1 @@ +"""Core modules forked from ccbot — transport-agnostic tmux/session management.""" diff --git a/ccweb/backend/core/hook.py b/ccweb/backend/core/hook.py new file mode 100644 index 00000000..f8e48b1e --- /dev/null +++ b/ccweb/backend/core/hook.py @@ -0,0 +1,225 @@ +"""Hook subcommand for Claude Code session tracking. + +Called by Claude Code's SessionStart hook to maintain a window-to-session +mapping in /session_map.json. Also provides --install to +auto-configure the hook in ~/.claude/settings.json. + +This module must NOT import config.py (which requires env vars for +server settings), since hooks run inside tmux panes where those vars +are not set. Config directory resolution uses utils.ccweb_dir(). + +Key functions: hook_main() (CLI entry), _install_hook(). +""" + +import argparse +import fcntl +import json +import logging +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Validate session_id looks like a UUID +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + +_CLAUDE_SETTINGS_FILE = Path.home() / ".claude" / "settings.json" + +# The hook command suffix for detection +_HOOK_COMMAND_SUFFIX = "ccweb hook" + + +def _find_ccweb_path() -> str: + """Find the full path to the ccweb executable.""" + ccweb_path = shutil.which("ccweb") + if ccweb_path: + return ccweb_path + + python_dir = Path(sys.executable).parent + ccweb_in_venv = python_dir / "ccweb" + if ccweb_in_venv.exists(): + return str(ccweb_in_venv) + + return "ccweb" + + +def _is_hook_installed(settings: dict) -> bool: # type: ignore[type-arg] + """Check if ccweb hook is already installed in the settings.""" + hooks = settings.get("hooks", {}) + session_start = hooks.get("SessionStart", []) + + for entry in session_start: + if not isinstance(entry, dict): + continue + inner_hooks = entry.get("hooks", []) + for h in inner_hooks: + if not isinstance(h, dict): + continue + cmd = h.get("command", "") + if cmd == _HOOK_COMMAND_SUFFIX or cmd.endswith("/" + _HOOK_COMMAND_SUFFIX): + return True + return False + + +def _install_hook() -> int: + """Install the ccweb hook into Claude's settings.json. Returns 0 on success.""" + settings_file = _CLAUDE_SETTINGS_FILE + settings_file.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} # type: ignore[type-arg] + if settings_file.exists(): + try: + settings = json.loads(settings_file.read_text()) + except (json.JSONDecodeError, OSError) as e: + print(f"Error reading {settings_file}: {e}", file=sys.stderr) + return 1 + + if _is_hook_installed(settings): + print(f"Hook already installed in {settings_file}") + return 0 + + ccweb_path = _find_ccweb_path() + hook_command = f"{ccweb_path} hook" + hook_config = {"type": "command", "command": hook_command, "timeout": 5} + + if "hooks" not in settings: + settings["hooks"] = {} + if "SessionStart" not in settings["hooks"]: + settings["hooks"]["SessionStart"] = [] + + settings["hooks"]["SessionStart"].append({"hooks": [hook_config]}) + + try: + settings_file.write_text( + json.dumps(settings, indent=2, ensure_ascii=False) + "\n" + ) + except OSError as e: + print(f"Error writing {settings_file}: {e}", file=sys.stderr) + return 1 + + print(f"Hook installed successfully in {settings_file}") + return 0 + + +def hook_main() -> None: + """Process a Claude Code hook event from stdin, or install the hook.""" + logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + level=logging.DEBUG, + stream=sys.stderr, + ) + + parser = argparse.ArgumentParser( + prog="ccweb hook", + description="Claude Code session tracking hook for CCWeb", + ) + parser.add_argument( + "--install", + action="store_true", + help="Install the hook into ~/.claude/settings.json", + ) + args, _ = parser.parse_known_args(sys.argv[2:]) + + if args.install: + sys.exit(_install_hook()) + + # Normal hook processing: read JSON from stdin + logger.debug("Processing hook event from stdin") + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse stdin JSON: %s", e) + return + + session_id = payload.get("session_id", "") + cwd = payload.get("cwd", "") + event = payload.get("hook_event_name", "") + + if not session_id or not event: + return + + if not _UUID_RE.match(session_id): + logger.warning("Invalid session_id format: %s", session_id) + return + + if cwd and not os.path.isabs(cwd): + logger.warning("cwd is not absolute: %s", cwd) + return + + if event != "SessionStart": + return + + # Get tmux session:window key + pane_id = os.environ.get("TMUX_PANE", "") + if not pane_id: + logger.warning("TMUX_PANE not set, cannot determine window") + return + + result = subprocess.run( + [ + "tmux", + "display-message", + "-t", + pane_id, + "-p", + "#{session_name}:#{window_id}:#{window_name}", + ], + capture_output=True, + text=True, + ) + raw_output = result.stdout.strip() + parts = raw_output.split(":", 2) + if len(parts) < 3: + logger.warning( + "Failed to parse tmux output (pane=%s, output=%s)", + pane_id, + raw_output, + ) + return + tmux_session_name, window_id, window_name = parts + session_window_key = f"{tmux_session_name}:{window_id}" + + # Write to ~/.ccweb/session_map.json (NOT ~/.ccbot/) + from .utils import atomic_write_json, ccweb_dir + + map_file = ccweb_dir() / "session_map.json" + map_file.parent.mkdir(parents=True, exist_ok=True) + + lock_path = map_file.with_suffix(".lock") + try: + with open(lock_path, "w") as lock_f: + fcntl.flock(lock_f, fcntl.LOCK_EX) + try: + session_map: dict[str, dict[str, str]] = {} + if map_file.exists(): + try: + session_map = json.loads(map_file.read_text()) + except (json.JSONDecodeError, OSError): + logger.warning("Failed to read session_map, starting fresh") + + session_map[session_window_key] = { + "session_id": session_id, + "cwd": cwd, + "window_name": window_name, + } + + # Clean up old-format key if it exists + old_key = f"{tmux_session_name}:{window_name}" + if old_key != session_window_key and old_key in session_map: + del session_map[old_key] + + atomic_write_json(map_file, session_map) + logger.info( + "Updated session_map: %s -> session_id=%s, cwd=%s", + session_window_key, + session_id, + cwd, + ) + finally: + fcntl.flock(lock_f, fcntl.LOCK_UN) + except OSError as e: + logger.error("Failed to write session_map: %s", e) diff --git a/ccweb/backend/core/monitor_state.py b/ccweb/backend/core/monitor_state.py new file mode 100644 index 00000000..41481787 --- /dev/null +++ b/ccweb/backend/core/monitor_state.py @@ -0,0 +1,109 @@ +"""Monitor state persistence — tracks byte offsets for each session. + +Persists TrackedSession records (session_id, file_path, last_byte_offset) +to ~/.ccweb/monitor_state.json so the session monitor can resume +incremental reading after restarts without re-sending old messages. + +Key classes: MonitorState, TrackedSession. +""" + +import json +import logging +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class TrackedSession: + """State for a tracked Claude Code session.""" + + session_id: str + file_path: str # Path to .jsonl file + last_byte_offset: int = 0 # Byte offset for incremental reading + + def to_dict(self) -> dict[str, Any]: + """Convert to dict for JSON serialization.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TrackedSession": + """Create from dict.""" + return cls( + session_id=data.get("session_id", ""), + file_path=data.get("file_path", ""), + last_byte_offset=data.get("last_byte_offset", 0), + ) + + +@dataclass +class MonitorState: + """Persistent state for the session monitor. + + Stores tracking information for all monitored sessions + to prevent duplicate notifications after restarts. + """ + + state_file: Path + tracked_sessions: dict[str, TrackedSession] = field(default_factory=dict) + _dirty: bool = field(default=False, repr=False) + + def load(self) -> None: + """Load state from file.""" + if not self.state_file.exists(): + logger.debug(f"State file does not exist: {self.state_file}") + return + + try: + data = json.loads(self.state_file.read_text()) + sessions = data.get("tracked_sessions", {}) + self.tracked_sessions = { + k: TrackedSession.from_dict(v) for k, v in sessions.items() + } + logger.info( + f"Loaded {len(self.tracked_sessions)} tracked sessions from state" + ) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning(f"Failed to load state file: {e}") + self.tracked_sessions = {} + + def save(self) -> None: + """Save state to file atomically.""" + from .utils import atomic_write_json + + data = { + "tracked_sessions": { + k: v.to_dict() for k, v in self.tracked_sessions.items() + } + } + + try: + atomic_write_json(self.state_file, data) + self._dirty = False + logger.debug( + "Saved %d tracked sessions to state", len(self.tracked_sessions) + ) + except OSError as e: + logger.error("Failed to save state file: %s", e) + + def get_session(self, session_id: str) -> TrackedSession | None: + """Get tracked session by ID.""" + return self.tracked_sessions.get(session_id) + + def update_session(self, session: TrackedSession) -> None: + """Update or add a tracked session.""" + self.tracked_sessions[session.session_id] = session + self._dirty = True + + def remove_session(self, session_id: str) -> None: + """Remove a tracked session.""" + if session_id in self.tracked_sessions: + del self.tracked_sessions[session_id] + self._dirty = True + + def save_if_dirty(self) -> None: + """Save state only if it has been modified.""" + if self._dirty: + self.save() diff --git a/ccweb/backend/core/session_monitor.py b/ccweb/backend/core/session_monitor.py new file mode 100644 index 00000000..318b50ad --- /dev/null +++ b/ccweb/backend/core/session_monitor.py @@ -0,0 +1,525 @@ +"""Session monitoring service — watches JSONL files for new messages. + +Runs an async polling loop that: + 1. Loads the current session_map to know which sessions to watch. + 2. Detects session_map changes (new/changed/deleted windows) and cleans up. + 3. Reads new JSONL lines from each session file using byte-offset tracking. + 4. Parses entries via TranscriptParser and emits NewMessage objects to a callback. + +Optimizations: file size check skips unchanged files; byte offset avoids re-reading. + +Key classes: SessionMonitor, NewMessage, SessionInfo. +""" + +import asyncio +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Awaitable + +import aiofiles + +from ..config import config +from .monitor_state import MonitorState, TrackedSession +from .tmux_manager import tmux_manager +from .transcript_parser import TranscriptParser +from .utils import read_cwd_from_jsonl + +logger = logging.getLogger(__name__) + + +@dataclass +class SessionInfo: + """Information about a Claude Code session.""" + + session_id: str + file_path: Path + + +@dataclass +class NewMessage: + """A new message detected by the monitor.""" + + session_id: str + text: str + is_complete: bool # True when stop_reason is set (final message) + content_type: str = "text" # "text" or "thinking" + tool_use_id: str | None = None + role: str = "assistant" # "user" or "assistant" + tool_name: str | None = None # For tool_use messages, the tool name + image_data: list[tuple[str, bytes]] | None = None # From tool_result images + + +class SessionMonitor: + """Monitors Claude Code sessions for new assistant messages. + + Uses simple async polling with aiofiles for non-blocking I/O. + Emits both intermediate and complete assistant messages. + """ + + def __init__( + self, + projects_path: Path | None = None, + poll_interval: float | None = None, + state_file: Path | None = None, + ): + self.projects_path = ( + projects_path if projects_path is not None else config.claude_projects_path + ) + self.poll_interval = ( + poll_interval if poll_interval is not None else config.monitor_poll_interval + ) + + self.state = MonitorState(state_file=state_file or config.monitor_state_file) + self.state.load() + + self._running = False + self._task: asyncio.Task | None = None + self._message_callback: Callable[[NewMessage], Awaitable[None]] | None = None + # Per-session pending tool_use state carried across poll cycles + self._pending_tools: dict[str, dict[str, Any]] = {} # session_id -> pending + # Track last known session_map for detecting changes + # Keys may be window_id (@12) or window_name (old format) during transition + self._last_session_map: dict[str, str] = {} # window_key -> session_id + + def set_message_callback( + self, callback: Callable[[NewMessage], Awaitable[None]] + ) -> None: + self._message_callback = callback + + async def _get_active_cwds(self) -> set[str]: + """Get normalized cwds of all active tmux windows.""" + cwds = set() + windows = await tmux_manager.list_windows() + for w in windows: + try: + cwds.add(str(Path(w.cwd).resolve())) + except (OSError, ValueError): + cwds.add(w.cwd) + return cwds + + async def scan_projects(self) -> list[SessionInfo]: + """Scan projects that have active tmux windows.""" + active_cwds = await self._get_active_cwds() + if not active_cwds: + return [] + + sessions = [] + + if not self.projects_path.exists(): + return sessions + + for project_dir in self.projects_path.iterdir(): + if not project_dir.is_dir(): + continue + + index_file = project_dir / "sessions-index.json" + original_path = "" + indexed_ids: set[str] = set() + + if index_file.exists(): + try: + async with aiofiles.open(index_file, "r") as f: + content = await f.read() + index_data = json.loads(content) + entries = index_data.get("entries", []) + original_path = index_data.get("originalPath", "") + + for entry in entries: + session_id = entry.get("sessionId", "") + full_path = entry.get("fullPath", "") + project_path = entry.get("projectPath", original_path) + + if not session_id or not full_path: + continue + + try: + norm_pp = str(Path(project_path).resolve()) + except (OSError, ValueError): + norm_pp = project_path + if norm_pp not in active_cwds: + continue + + indexed_ids.add(session_id) + file_path = Path(full_path) + if file_path.exists(): + sessions.append( + SessionInfo( + session_id=session_id, + file_path=file_path, + ) + ) + + except (json.JSONDecodeError, OSError) as e: + logger.debug(f"Error reading index {index_file}: {e}") + + # Pick up un-indexed .jsonl files + try: + for jsonl_file in project_dir.glob("*.jsonl"): + session_id = jsonl_file.stem + if session_id in indexed_ids: + continue + + # Determine project_path for this file + file_project_path = original_path + if not file_project_path: + file_project_path = await asyncio.to_thread( + read_cwd_from_jsonl, jsonl_file + ) + if not file_project_path: + dir_name = project_dir.name + if dir_name.startswith("-"): + file_project_path = dir_name.replace("-", "/") + + try: + norm_fp = str(Path(file_project_path).resolve()) + except (OSError, ValueError): + norm_fp = file_project_path + + if norm_fp not in active_cwds: + continue + + sessions.append( + SessionInfo( + session_id=session_id, + file_path=jsonl_file, + ) + ) + except OSError as e: + logger.debug(f"Error scanning jsonl files in {project_dir}: {e}") + + return sessions + + async def _read_new_lines( + self, session: TrackedSession, file_path: Path + ) -> list[dict]: + """Read new lines from a session file using byte offset for efficiency. + + Detects file truncation (e.g. after /clear) and resets offset. + Recovers from corrupted offsets (mid-line) by scanning to next line. + """ + new_entries = [] + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + # Get file size to detect truncation + await f.seek(0, 2) # Seek to end + file_size = await f.tell() + + # Detect file truncation: if offset is beyond file size, reset + if session.last_byte_offset > file_size: + logger.info( + "File truncated for session %s " + "(offset %d > size %d). Resetting.", + session.session_id, + session.last_byte_offset, + file_size, + ) + session.last_byte_offset = 0 + + # Seek to last read position for incremental reading + await f.seek(session.last_byte_offset) + + # Detect corrupted offset: if we're mid-line (not at '{'), + # scan forward to the next line start. This can happen if + # the state file was manually edited or corrupted. + if session.last_byte_offset > 0: + first_char = await f.read(1) + if first_char and first_char != "{": + logger.warning( + "Corrupted offset %d in session %s (mid-line), " + "scanning to next line", + session.last_byte_offset, + session.session_id, + ) + await f.readline() # Skip rest of partial line + session.last_byte_offset = await f.tell() + return [] + await f.seek(session.last_byte_offset) # Reset for normal read + + # Read only new lines from the offset. + # Track safe_offset: only advance past lines that parsed + # successfully. A non-empty line that fails JSON parsing is + # likely a partial write; stop and retry next cycle. + safe_offset = session.last_byte_offset + async for line in f: + data = TranscriptParser.parse_line(line) + if data: + new_entries.append(data) + safe_offset = await f.tell() + elif line.strip(): + # Partial JSONL line — don't advance offset past it + logger.warning( + "Partial JSONL line in session %s, will retry next cycle", + session.session_id, + ) + break + else: + # Empty line — safe to skip + safe_offset = await f.tell() + + session.last_byte_offset = safe_offset + + except OSError as e: + logger.error("Error reading session file %s: %s", file_path, e) + return new_entries + + async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessage]: + """Check all sessions for new assistant messages. + + Reads from last byte offset. Emits both intermediate + (stop_reason=null) and complete messages. + + Args: + active_session_ids: Set of session IDs currently in session_map + """ + new_messages = [] + + # Scan projects to get available session files + sessions = await self.scan_projects() + + # Only process sessions that are in session_map + for session_info in sessions: + if session_info.session_id not in active_session_ids: + continue + try: + tracked = self.state.get_session(session_info.session_id) + + if tracked is None: + # For new sessions, initialize offset to end of file + # to avoid re-processing old messages + try: + file_size = session_info.file_path.stat().st_size + except OSError: + file_size = 0 + tracked = TrackedSession( + session_id=session_info.session_id, + file_path=str(session_info.file_path), + last_byte_offset=file_size, + ) + self.state.update_session(tracked) + logger.info(f"Started tracking session: {session_info.session_id}") + continue + + # Quick size check: skip reading if file size hasn't changed. + # For append-only JSONL files, size == offset means no new + # content. Size < offset (truncation) and size > offset (new + # data) both need processing — handled inside _read_new_lines. + try: + current_size = session_info.file_path.stat().st_size + except OSError: + continue + + if current_size == tracked.last_byte_offset: + continue + + # File changed, read new content from last offset + new_entries = await self._read_new_lines( + tracked, session_info.file_path + ) + + if new_entries: + logger.debug( + f"Read {len(new_entries)} new entries for " + f"session {session_info.session_id}" + ) + + # Parse new entries using the shared logic, carrying over pending tools + carry = self._pending_tools.get(session_info.session_id, {}) + parsed_entries, remaining = TranscriptParser.parse_entries( + new_entries, + pending_tools=carry, + ) + if remaining: + self._pending_tools[session_info.session_id] = remaining + else: + self._pending_tools.pop(session_info.session_id, None) + + for entry in parsed_entries: + if not entry.text and not entry.image_data: + continue + # Skip user messages unless show_user_messages is enabled + if entry.role == "user" and not config.show_user_messages: + continue + new_messages.append( + NewMessage( + session_id=session_info.session_id, + text=entry.text, + is_complete=True, + content_type=entry.content_type, + tool_use_id=entry.tool_use_id, + role=entry.role, + tool_name=entry.tool_name, + image_data=entry.image_data, + ) + ) + + self.state.update_session(tracked) + + except OSError as e: + logger.debug(f"Error processing session {session_info.session_id}: {e}") + + # NOTE: save_if_dirty() is intentionally NOT called here. + # Offsets must be persisted only AFTER delivery to the client (in + # _monitor_loop) to guarantee at-least-once delivery. Saving before + # delivery would risk silent message loss on crash. + return new_messages + + async def _load_current_session_map(self) -> dict[str, str]: + """Load current session_map and return window_key -> session_id mapping. + + Keys in session_map are formatted as "tmux_session:window_id" + (e.g. "ccbot:@12"). Old-format keys ("ccbot:window_name") are also + accepted so that sessions running before a code upgrade continue + to be monitored until the hook re-fires with new format. + Only entries matching our tmux_session_name are processed. + """ + window_to_session: dict[str, str] = {} + if config.session_map_file.exists(): + try: + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + prefix = f"{config.tmux_session_name}:" + for key, info in session_map.items(): + # Only process entries for our tmux session + if not key.startswith(prefix): + continue + window_key = key[len(prefix) :] + session_id = info.get("session_id", "") + if session_id: + window_to_session[window_key] = session_id + except (json.JSONDecodeError, OSError): + pass + return window_to_session + + async def _cleanup_all_stale_sessions(self) -> None: + """Clean up all tracked sessions not in current session_map (used on startup).""" + current_map = await self._load_current_session_map() + active_session_ids = set(current_map.values()) + + stale_sessions = [] + for session_id in self.state.tracked_sessions.keys(): + if session_id not in active_session_ids: + stale_sessions.append(session_id) + + if stale_sessions: + logger.info( + f"[Startup cleanup] Removing {len(stale_sessions)} stale sessions" + ) + for session_id in stale_sessions: + self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) + self.state.save_if_dirty() + + async def _detect_and_cleanup_changes(self) -> dict[str, str]: + """Detect session_map changes and cleanup replaced/removed sessions. + + Returns current session_map for further processing. + """ + current_map = await self._load_current_session_map() + + sessions_to_remove: set[str] = set() + + # Check for window session changes (window exists in both, but session_id changed) + for window_id, old_session_id in self._last_session_map.items(): + new_session_id = current_map.get(window_id) + if new_session_id and new_session_id != old_session_id: + logger.info( + "Window '%s' session changed: %s -> %s", + window_id, + old_session_id, + new_session_id, + ) + sessions_to_remove.add(old_session_id) + + # Check for deleted windows (window in old map but not in current) + old_windows = set(self._last_session_map.keys()) + current_windows = set(current_map.keys()) + deleted_windows = old_windows - current_windows + + for window_id in deleted_windows: + old_session_id = self._last_session_map[window_id] + logger.info( + "Window '%s' deleted, removing session %s", + window_id, + old_session_id, + ) + sessions_to_remove.add(old_session_id) + + # Perform cleanup + if sessions_to_remove: + for session_id in sessions_to_remove: + self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) + self.state.save_if_dirty() + + # Update last known map + self._last_session_map = current_map + + return current_map + + async def _monitor_loop(self) -> None: + """Background loop for checking session updates. + + Uses simple async polling with aiofiles for non-blocking I/O. + """ + logger.info("Session monitor started, polling every %ss", self.poll_interval) + + # Deferred import to avoid circular dependency (cached once) + from ..session import session_manager + + # Clean up all stale sessions on startup + await self._cleanup_all_stale_sessions() + # Initialize last known session_map + self._last_session_map = await self._load_current_session_map() + + while self._running: + try: + # Load hook-based session map updates + await session_manager.load_session_map() + + # Detect session_map changes and cleanup replaced/removed sessions + current_map = await self._detect_and_cleanup_changes() + active_session_ids = set(current_map.values()) + + # Check for new messages (all I/O is async) + new_messages = await self.check_for_updates(active_session_ids) + + for msg in new_messages: + status = "complete" if msg.is_complete else "streaming" + preview = msg.text[:80] + ("..." if len(msg.text) > 80 else "") + logger.info("[%s] session=%s: %s", status, msg.session_id, preview) + if self._message_callback: + try: + await self._message_callback(msg) + except Exception as e: + logger.error(f"Message callback error: {e}") + + # Persist byte offsets AFTER delivering messages to the client. + # This guarantees at-least-once delivery: if the server crashes + # before this save, messages will be re-read and re-delivered + # on restart (safe duplicate) rather than silently lost. + self.state.save_if_dirty() + + except Exception as e: + logger.error(f"Monitor loop error: {e}") + + await asyncio.sleep(self.poll_interval) + + logger.info("Session monitor stopped") + + def start(self) -> None: + if self._running: + logger.warning("Monitor already running") + return + self._running = True + self._task = asyncio.create_task(self._monitor_loop()) + + def stop(self) -> None: + self._running = False + if self._task: + self._task.cancel() + self._task = None + self.state.save() + logger.info("Session monitor stopped and state saved") diff --git a/ccweb/backend/core/terminal_parser.py b/ccweb/backend/core/terminal_parser.py new file mode 100644 index 00000000..1afefed0 --- /dev/null +++ b/ccweb/backend/core/terminal_parser.py @@ -0,0 +1,365 @@ +"""Terminal output parser — detects Claude Code UI elements in pane text. + +Parses captured tmux pane content to detect: + - Interactive UIs (AskUserQuestion, ExitPlanMode, Permission Prompt, + RestoreCheckpoint) via regex-based UIPattern matching with top/bottom + delimiters. + - Status line (spinner characters + working text) by scanning from bottom up. + +All Claude Code text patterns live here. To support a new UI type or +a changed Claude Code version, edit UI_PATTERNS / STATUS_SPINNERS. + +Key functions: is_interactive_ui(), extract_interactive_content(), +parse_status_line(), strip_pane_chrome(), extract_bash_output(). +""" + +import re +from dataclasses import dataclass + + +@dataclass +class InteractiveUIContent: + """Content extracted from an interactive UI.""" + + content: str # The extracted display content + name: str = "" # Pattern name that matched (e.g. "AskUserQuestion") + + +@dataclass(frozen=True) +class UIPattern: + """A text-marker pair that delimits an interactive UI region. + + Extraction scans lines top-down: the first line matching any `top` pattern + marks the start, the first subsequent line matching any `bottom` pattern + marks the end. Both boundary lines are included in the extracted content. + + ``top`` and ``bottom`` are tuples of compiled regexes — any single match + is sufficient. This accommodates wording changes across Claude Code + versions (e.g. a reworded confirmation prompt). + """ + + name: str # Descriptive label (not used programmatically) + top: tuple[re.Pattern[str], ...] + bottom: tuple[re.Pattern[str], ...] + min_gap: int = 2 # minimum lines between top and bottom (inclusive) + + +# ── UI pattern definitions (order matters — first match wins) ──────────── + +UI_PATTERNS: list[UIPattern] = [ + UIPattern( + name="ExitPlanMode", + top=( + re.compile(r"^\s*Would you like to proceed\?"), + # v2.1.29+: longer prefix that may wrap across lines + re.compile(r"^\s*Claude has written up a plan"), + ), + bottom=( + re.compile(r"^\s*ctrl-g to edit in "), + re.compile(r"^\s*Esc to (cancel|exit)"), + ), + ), + UIPattern( + name="AskUserQuestion", + top=(re.compile(r"^\s*←\s+[☐✔☒]"),), # Multi-tab: no bottom needed + bottom=(), + min_gap=1, + ), + UIPattern( + name="AskUserQuestion", + top=(re.compile(r"^\s*[☐✔☒]"),), # Single-tab: bottom required + bottom=(re.compile(r"^\s*Enter to select"),), + min_gap=1, + ), + UIPattern( + name="PermissionPrompt", + top=( + re.compile(r"^\s*Do you want to proceed\?"), + 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"), + ), + 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"),), + bottom=(), + min_gap=2, + ), + UIPattern( + # Bash command approval + name="BashApproval", + top=( + re.compile(r"^\s*Bash command\s*$"), + re.compile(r"^\s*This command requires approval"), + ), + bottom=(re.compile(r"^\s*Esc to cancel"),), + ), + UIPattern( + name="RestoreCheckpoint", + top=(re.compile(r"^\s*Restore the code"),), + bottom=(re.compile(r"^\s*Enter to continue"),), + ), + UIPattern( + name="Settings", + top=( + re.compile(r"^\s*Settings:.*tab to cycle"), + re.compile(r"^\s*Select model"), + ), + bottom=( + re.compile(r"Esc to cancel"), + re.compile(r"Esc to exit"), + re.compile(r"Enter to confirm"), + re.compile(r"^\s*Type to filter"), + ), + ), +] + + +# ── Post-processing ────────────────────────────────────────────────────── + +_RE_LONG_DASH = re.compile(r"^─{5,}$") + + +def _shorten_separators(text: str) -> str: + """Replace lines of 5+ ─ characters with exactly ─────.""" + return "\n".join( + "─────" if _RE_LONG_DASH.match(line) else line for line in text.split("\n") + ) + + +# ── Core extraction ────────────────────────────────────────────────────── + + +def _try_extract(lines: list[str], pattern: UIPattern) -> InteractiveUIContent | None: + """Try to extract content matching a single UI pattern. + + When ``pattern.bottom`` is empty, the region extends from the top marker + to the last non-empty line (used for multi-tab AskUserQuestion where the + bottom delimiter varies by tab). + """ + top_idx: int | None = None + bottom_idx: int | None = None + + for i, line in enumerate(lines): + if top_idx is None: + if any(p.search(line) for p in pattern.top): + top_idx = i + elif pattern.bottom and any(p.search(line) for p in pattern.bottom): + bottom_idx = i + break + + if top_idx is None: + return None + + # No bottom patterns → use last non-empty line as boundary + if not pattern.bottom: + for i in range(len(lines) - 1, top_idx, -1): + if lines[i].strip(): + bottom_idx = i + break + + if bottom_idx is None or bottom_idx - top_idx < pattern.min_gap: + return None + + content = "\n".join(lines[top_idx : bottom_idx + 1]).rstrip() + return InteractiveUIContent(content=_shorten_separators(content), name=pattern.name) + + +# ── Public API ─────────────────────────────────────────────────────────── + + +def extract_interactive_content(pane_text: str) -> InteractiveUIContent | None: + """Extract content from an interactive UI in terminal output. + + Tries each UI pattern in declaration order; first match wins. + Returns None if no recognizable interactive UI is found. + """ + if not pane_text: + return None + + lines = pane_text.strip().split("\n") + for pattern in UI_PATTERNS: + result = _try_extract(lines, pattern) + if result: + return result + return None + + +def is_interactive_ui(pane_text: str) -> bool: + """Check if terminal currently shows an interactive UI.""" + return extract_interactive_content(pane_text) is not None + + +# ── Status line parsing ───────────────────────────────────────────────── + +# Spinner characters Claude Code uses in its status line +STATUS_SPINNERS = frozenset(["·", "✻", "✽", "✶", "✳", "✢"]) + + +def parse_status_line(pane_text: str) -> str | None: + """Extract the Claude Code status line from terminal output. + + The status line (spinner + working text) appears immediately above + the chrome separator (a full line of ``─`` characters). We locate + the separator first, then check the line just above it — this avoids + false positives from ``·`` bullets in Claude's regular output. + + Returns the text after the spinner, or None if no status line found. + """ + if not pane_text: + return None + + lines = pane_text.split("\n") + + # Find the chrome separator: topmost ──── line in the last 10 lines + chrome_idx: int | None = None + search_start = max(0, len(lines) - 10) + for i in range(search_start, len(lines)): + stripped = lines[i].strip() + if len(stripped) >= 20 and all(c == "─" for c in stripped): + chrome_idx = i + break + + if chrome_idx is None: + return None # No chrome visible — can't determine status + + # Check lines just above the separator (skip blanks, up to 4 lines) + for i in range(chrome_idx - 1, max(chrome_idx - 5, -1), -1): + line = lines[i].strip() + if not line: + continue + if line[0] in STATUS_SPINNERS: + return line[1:].strip() + # First non-empty line above separator isn't a spinner → no status + return None + return None + + +# ── Pane chrome stripping & bash output extraction ───────────────────── + + +def strip_pane_chrome(lines: list[str]) -> list[str]: + """Strip Claude Code's bottom chrome (prompt area + status bar). + + The bottom of the pane looks like:: + + ──────────────────────── (separator) + ❯ (prompt) + ──────────────────────── (separator) + [Opus 4.6] Context: 34% + ⏵⏵ bypass permissions… + + This function finds the topmost ``────`` separator in the last 10 lines + and strips everything from there down. + """ + search_start = max(0, len(lines) - 10) + for i in range(search_start, len(lines)): + stripped = lines[i].strip() + if len(stripped) >= 20 and all(c == "─" for c in stripped): + return lines[:i] + return lines + + +def extract_bash_output(pane_text: str, command: str) -> str | None: + """Extract ``!`` command output from a captured tmux pane. + + Searches from the bottom for the ``! `` echo line, then + returns that line and everything below it (including the ``⎿`` output). + Returns *None* if the command echo wasn't found. + """ + lines = strip_pane_chrome(pane_text.splitlines()) + + # Find the last "! " echo line (search from bottom). + # Match on the first 10 chars of the command in case the line is truncated. + cmd_idx: int | None = None + match_prefix = command[:10] + for i in range(len(lines) - 1, -1, -1): + stripped = lines[i].strip() + if stripped.startswith(f"! {match_prefix}") or stripped.startswith( + f"!{match_prefix}" + ): + cmd_idx = i + break + + if cmd_idx is None: + return None + + # Include the command echo line and everything after it + raw_output = lines[cmd_idx:] + + # Strip trailing empty lines + while raw_output and not raw_output[-1].strip(): + raw_output.pop() + + if not raw_output: + return None + + return "\n".join(raw_output).strip() + + +# ── Usage modal parsing ────────────────────────────────────────────────────────── + + +@dataclass +class UsageInfo: + """Parsed output from Claude Code's /usage modal.""" + + raw_text: str # Full captured pane text + parsed_lines: list[str] # Cleaned content lines from the modal + + +def parse_usage_output(pane_text: str) -> UsageInfo | None: + """Extract usage information from Claude Code's /usage settings tab. + + The /usage modal shows a Settings overlay with a "Usage" tab containing + progress bars and reset times. This parser looks for the Settings header + line, then collects all content until "Esc to cancel". + + Returns UsageInfo with cleaned lines, or None if not detected. + """ + if not pane_text: + return None + + lines = pane_text.strip().split("\n") + + # Find the Settings header that indicates we're in the usage modal + start_idx: int | None = None + end_idx: int | None = None + + for i, line in enumerate(lines): + stripped = line.strip() + if start_idx is None: + # The usage tab header line + if "Settings:" in stripped and "Usage" in stripped: + start_idx = i + 1 # skip the header itself + else: + if stripped.startswith("Esc to"): + end_idx = i + break + + if start_idx is None: + return None + if end_idx is None: + end_idx = len(lines) + + # Collect content lines, stripping progress bar characters and whitespace + cleaned: list[str] = [] + for line in lines[start_idx:end_idx]: + # Strip the line but preserve meaningful content + stripped = line.strip() + if not stripped: + continue + # Remove progress bar block characters but keep the rest + # Progress bars are like: █████▋ 38% used + # Strip leading block chars, keep the percentage + stripped = re.sub(r"^[\u2580-\u259f\s]+", "", stripped).strip() + if stripped: + cleaned.append(stripped) + + if cleaned: + return UsageInfo(raw_text=pane_text, parsed_lines=cleaned) + + return None diff --git a/ccweb/backend/core/tmux_manager.py b/ccweb/backend/core/tmux_manager.py new file mode 100644 index 00000000..0ec01cc4 --- /dev/null +++ b/ccweb/backend/core/tmux_manager.py @@ -0,0 +1,477 @@ +"""Tmux session/window management via libtmux. + +Wraps libtmux to provide async-friendly operations on a single tmux session: + - list_windows / find_window_by_name: discover Claude Code windows. + - capture_pane: read terminal content (plain or with ANSI colors). + - send_keys: forward user input or control keys to a window. + - create_window / kill_window: lifecycle management. + +All blocking libtmux calls are wrapped in asyncio.to_thread(). + +Key class: TmuxManager (singleton instantiated as `tmux_manager`). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +import libtmux + +from ..config import SENSITIVE_ENV_VARS, config + +logger = logging.getLogger(__name__) + +# Process names that indicate a bare shell (Claude Code has exited). +# Used to prevent sending user input to a shell prompt. +SHELL_COMMANDS = frozenset( + { + "bash", + "zsh", + "sh", + "fish", + "dash", + "tcsh", + "csh", + "ksh", + "ash", + } +) + + +@dataclass +class TmuxWindow: + """Information about a tmux window.""" + + window_id: str + window_name: str + cwd: str # Current working directory + pane_current_command: str = "" # Process running in active pane + + +class TmuxManager: + """Manages tmux windows for Claude Code sessions.""" + + # How long cached list_windows results are valid (seconds). + _CACHE_TTL = 1.0 + + def __init__(self, session_name: str | None = None): + """Initialize tmux manager. + + Args: + session_name: Name of the tmux session to use (default from config) + """ + self.session_name = session_name or config.tmux_session_name + self._server: libtmux.Server | None = None + self._windows_cache: list[TmuxWindow] | None = None + self._windows_cache_time: float = 0.0 + + @property + def server(self) -> libtmux.Server: + """Get or create tmux server connection.""" + if self._server is None: + self._server = libtmux.Server() + return self._server + + def get_session(self) -> libtmux.Session | None: + """Get the tmux session if it exists.""" + try: + return self.server.sessions.get(session_name=self.session_name) + except Exception: + return None + + def get_or_create_session(self) -> libtmux.Session: + """Get existing session or create a new one.""" + session = self.get_session() + if session: + self._scrub_session_env(session) + return session + + # Create new session with main window named specifically + session = self.server.new_session( + session_name=self.session_name, + start_directory=str(Path.home()), + ) + # Rename the default window to the main window name + if session.windows: + session.windows[0].rename_window(config.tmux_main_window_name) + self._scrub_session_env(session) + return session + + @staticmethod + def _scrub_session_env(session: libtmux.Session) -> None: + """Remove sensitive env vars from the tmux session environment. + + Prevents new windows (and their child processes like Claude Code) + from inheriting secrets such as auth tokens. + """ + for var in SENSITIVE_ENV_VARS: + try: + session.unset_environment(var) + except Exception: + pass # var not set in session env — nothing to remove + + def invalidate_cache(self) -> None: + """Invalidate the cached window list (call after mutations).""" + self._windows_cache = None + + async def list_windows(self) -> list[TmuxWindow]: + """List all windows in the session with their working directories. + + Results are cached for ``_CACHE_TTL`` seconds to avoid hammering + the tmux server when multiple callers need window info in the same + poll cycle. + """ + now = time.monotonic() + if ( + self._windows_cache is not None + and (now - self._windows_cache_time) < self._CACHE_TTL + ): + return self._windows_cache + + def _sync_list_windows() -> list[TmuxWindow]: + windows = [] + session = self.get_session() + + if not session: + return windows + + for window in session.windows: + name = window.window_name or "" + # Skip the main window (placeholder window) + if name == config.tmux_main_window_name: + continue + + try: + # Get the active pane's current path and command + pane = window.active_pane + if pane: + cwd = pane.pane_current_path or "" + pane_cmd = pane.pane_current_command or "" + else: + cwd = "" + pane_cmd = "" + + windows.append( + TmuxWindow( + window_id=window.window_id or "", + window_name=name, + cwd=cwd, + pane_current_command=pane_cmd, + ) + ) + except Exception as e: + logger.debug(f"Error getting window info: {e}") + + return windows + + result = await asyncio.to_thread(_sync_list_windows) + self._windows_cache = result + self._windows_cache_time = time.monotonic() + return result + + async def find_window_by_name(self, window_name: str) -> TmuxWindow | None: + """Find a window by its name. + + Args: + window_name: The window name to match + + Returns: + TmuxWindow if found, None otherwise + """ + windows = await self.list_windows() + for window in windows: + if window.window_name == window_name: + return window + logger.debug("Window not found by name: %s", window_name) + return None + + async def find_window_by_id(self, window_id: str) -> TmuxWindow | None: + """Find a window by its tmux window ID (e.g. '@0', '@12'). + + Args: + window_id: The tmux window ID to match + + Returns: + TmuxWindow if found, None otherwise + """ + windows = await self.list_windows() + for window in windows: + if window.window_id == window_id: + return window + logger.debug("Window not found by id: %s", window_id) + return None + + async def get_pane_pid(self, window_id: str) -> int | None: + """Get the PID of the shell process in a window's active pane.""" + try: + proc = await asyncio.create_subprocess_exec( + "tmux", + "display-message", + "-p", + "-t", + window_id, + "#{pane_pid}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + pid_str = stdout.decode("utf-8").strip() + if pid_str: + return int(pid_str) + except (OSError, ValueError) as e: + logger.debug("Failed to get pane PID for %s: %s", window_id, e) + return None + + async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | None: + """Capture the visible text content of a window's active pane. + + Uses a direct ``tmux capture-pane`` subprocess for both plain text + and ANSI modes — avoids the multiple tmux round-trips that libtmux + would generate (list-windows → list-panes → capture-pane). + """ + cmd = ["tmux", "capture-pane", "-p", "-t", window_id] + if with_ansi: + cmd.insert(2, "-e") + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return stdout.decode("utf-8") + logger.error( + "Failed to capture pane %s: %s", window_id, stderr.decode("utf-8") + ) + return None + except Exception as e: + logger.error("Unexpected error capturing pane %s: %s", window_id, e) + return None + + async def send_keys( + self, window_id: str, text: str, enter: bool = True, literal: bool = True + ) -> bool: + """Send keys to a specific window. + + Args: + window_id: The window ID to send to + text: Text to send + 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". + + Returns: + True if successful, False otherwise + """ + if literal and enter: + # Split into text + delay + Enter via libtmux. + # Claude Code's TUI sometimes interprets a rapid-fire Enter + # (arriving in the same input batch as the text) as a newline + # rather than submit. A 500ms gap lets the TUI process the + # text before receiving Enter. + def _send_literal(chars: str) -> 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 + pane.send_keys(chars, enter=False, literal=True) + return True + except Exception as e: + logger.error(f"Failed to send keys 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 send Enter to window {window_id}: {e}") + return False + + # Claude Code's ! command mode: send "!" first so the TUI + # switches to bash mode, wait 1s, then send the rest. + if text.startswith("!"): + if not await asyncio.to_thread(_send_literal, "!"): + return False + rest = text[1:] + if rest: + await asyncio.sleep(1.0) + if not await asyncio.to_thread(_send_literal, rest): + return False + else: + if not await asyncio.to_thread(_send_literal, text): + return False + await asyncio.sleep(0.5) + return await asyncio.to_thread(_send_enter) + + # Other cases: special keys (literal=False) or no-enter + def _sync_send_keys() -> 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 + + pane.send_keys(text, enter=enter, literal=literal) + return True + + except Exception as e: + logger.error(f"Failed to send keys to window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_send_keys) + + async def rename_window(self, window_id: str, new_name: str) -> bool: + """Rename a tmux window by its ID.""" + self.invalidate_cache() + + def _sync_rename() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + window.rename_window(new_name) + logger.info("Renamed window %s to '%s'", window_id, new_name) + return True + except Exception as e: + logger.error(f"Failed to rename window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_rename) + + async def kill_window(self, window_id: str) -> bool: + """Kill a tmux window by its ID.""" + self.invalidate_cache() + + def _sync_kill() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + window.kill() + logger.info("Killed window %s", window_id) + return True + except Exception as e: + logger.error(f"Failed to kill window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_kill) + + async def create_window( + self, + work_dir: str, + window_name: str | None = None, + start_claude: bool = True, + ) -> tuple[bool, str, str, str]: + """Create a new tmux window and optionally start Claude Code. + + Args: + work_dir: Working directory for the new window + window_name: Optional window name (defaults to directory name) + start_claude: Whether to start claude command + + Returns: + Tuple of (success, message, window_name, window_id) + """ + # Validate directory first + path = Path(work_dir).expanduser().resolve() + if not path.exists(): + return False, f"Directory does not exist: {work_dir}", "", "" + if not path.is_dir(): + return False, f"Not a directory: {work_dir}", "", "" + + # Create window name, adding suffix if name already exists + final_window_name = window_name if window_name else path.name + + # Check for existing window name + base_name = final_window_name + counter = 2 + while await self.find_window_by_name(final_window_name): + final_window_name = f"{base_name}-{counter}" + counter += 1 + + # Create window in thread + self.invalidate_cache() + + def _create_and_start() -> tuple[bool, str, str, str]: + session = self.get_or_create_session() + try: + # Create new window + window = session.new_window( + window_name=final_window_name, + start_directory=str(path), + ) + + wid = window.window_id or "" + + # Prevent Claude Code from overriding window name + window.set_window_option("allow-rename", "off") + + # Start Claude Code if requested + if start_claude: + pane = window.active_pane + if pane: + pane.send_keys(config.claude_command, enter=True) + + logger.info( + "Created window '%s' (id=%s) at %s", + final_window_name, + wid, + path, + ) + return ( + True, + f"Created window '{final_window_name}' at {path}", + final_window_name, + wid, + ) + + except Exception as e: + logger.error(f"Failed to create window: {e}") + return False, f"Failed to create window: {e}", "", "" + + return await asyncio.to_thread(_create_and_start) + + +# Global instance with default session name +tmux_manager = TmuxManager() diff --git a/ccweb/backend/core/transcript_parser.py b/ccweb/backend/core/transcript_parser.py new file mode 100644 index 00000000..35c84419 --- /dev/null +++ b/ccweb/backend/core/transcript_parser.py @@ -0,0 +1,774 @@ +"""JSONL transcript parser for Claude Code session files. + +Parses Claude Code session JSONL files and extracts structured messages. +Handles: text, thinking, tool_use, tool_result, local_command, and user messages. +Tool pairing: tool_use blocks in assistant messages are matched with +tool_result blocks in subsequent user messages via tool_use_id. + +Forked from ccbot — removed Telegram expandable quote sentinels. +Collapsible content is handled by the React frontend, not by text markers. + +Key classes: TranscriptParser (static methods), ParsedEntry, ParsedMessage, PendingToolInfo. +""" + +import base64 +import difflib +import json +import logging +import re +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class ParsedMessage: + """Parsed message from a transcript.""" + + message_type: str # "user", "assistant", "tool_use", "tool_result", etc. + text: str # Extracted text content + tool_name: str | None = None # For tool_use messages + + +@dataclass +class ParsedEntry: + """A single parsed message entry ready for display.""" + + role: str # "user" | "assistant" + text: str # Already formatted text + content_type: ( + str # "text" | "thinking" | "tool_use" | "tool_result" | "local_command" + ) + tool_use_id: str | None = None + timestamp: str | None = None # ISO timestamp from JSONL + tool_name: str | None = ( + None # For tool_use entries, the tool name (e.g. "AskUserQuestion") + ) + image_data: list[tuple[str, bytes]] | None = ( + None # For tool_result entries with images: (media_type, raw_bytes) + ) + + +@dataclass +class PendingToolInfo: + """Information about a pending tool_use waiting for its tool_result.""" + + summary: str # Formatted tool summary (e.g. "**Read**(file.py)") + tool_name: str # Tool name (e.g. "Read", "Edit") + input_data: Any = None # Tool input parameters (for Edit to generate diff) + + +class TranscriptParser: + """Parser for Claude Code JSONL session files. + + Expected JSONL entry structure: + - type: "user" | "assistant" | "summary" | "file-history-snapshot" | ... + - message.content: list of blocks (text, tool_use, tool_result, thinking) + - sessionId, cwd, timestamp, uuid: metadata fields + + Tool pairing model: tool_use blocks appear in assistant messages, + matching tool_result blocks appear in the next user message (keyed by tool_use_id). + """ + + # Magic string constants + _NO_CONTENT_PLACEHOLDER = "(no content)" + _INTERRUPTED_TEXT = "[Request interrupted by user for tool use]" + _MAX_SUMMARY_LENGTH = 200 + + @staticmethod + def parse_line(line: str) -> dict | None: + """Parse a single JSONL line. + + Args: + line: A single line from the JSONL file + + Returns: + Parsed dict or None if line is empty/invalid + """ + line = line.strip() + if not line: + return None + + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + @staticmethod + def get_message_type(data: dict) -> str | None: + """Get the message type from parsed data. + + Returns: + Message type: "user", "assistant", "file-history-snapshot", etc. + """ + return data.get("type") + + @staticmethod + def is_user_message(data: dict) -> bool: + """Check if this is a user message.""" + return data.get("type") == "user" + + @staticmethod + def extract_text_only(content_list: list[Any]) -> str: + """Extract only text content from structured content. + + This is used for Telegram notifications where we only want + the actual text response, not tool calls or thinking. + + Args: + content_list: List of content blocks + + Returns: + Combined text content only + """ + if not isinstance(content_list, list): + if isinstance(content_list, str): + return content_list + return "" + + texts = [] + for item in content_list: + if isinstance(item, str): + texts.append(item) + elif isinstance(item, dict): + if item.get("type") == "text": + text = item.get("text", "") + if text: + texts.append(text) + + return "\n".join(texts) + + _RE_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + _RE_COMMAND_NAME = re.compile(r"(.*?)") + _RE_LOCAL_STDOUT = re.compile( + r"(.*?)", re.DOTALL + ) + _RE_SYSTEM_TAGS = re.compile( + r"<(bash-input|bash-stdout|bash-stderr|local-command-caveat|system-reminder)" + ) + + @staticmethod + def _format_edit_diff(old_string: str, new_string: str) -> str: + """Generate a compact unified diff between old_string and new_string.""" + old_lines = old_string.splitlines(keepends=True) + new_lines = new_string.splitlines(keepends=True) + diff = difflib.unified_diff(old_lines, new_lines, lineterm="") + # Skip the --- / +++ header lines + result_lines: list[str] = [] + for line in diff: + if line.startswith("---") or line.startswith("+++"): + continue + # Strip trailing newline for clean display + result_lines.append(line.rstrip("\n")) + return "\n".join(result_lines) + + @classmethod + def format_tool_use_summary(cls, name: str, input_data: dict | Any) -> str: + """Format a tool_use block into a brief summary line. + + Args: + name: Tool name (e.g. "Read", "Write", "Bash") + input_data: The tool input dict + + Returns: + Formatted string like "**Read**(file.py)" + """ + if not isinstance(input_data, dict): + return f"**{name}**" + + # Pick a meaningful short summary based on tool name + summary = "" + if name in ("Read", "Glob"): + summary = input_data.get("file_path") or input_data.get("pattern", "") + elif name == "Write": + summary = input_data.get("file_path", "") + elif name in ("Edit", "NotebookEdit"): + summary = input_data.get("file_path") or input_data.get("notebook_path", "") + # Note: Edit/Update diff and stats are generated in tool_result stage, + # not here. We just show the tool name and file path. + elif name == "Bash": + summary = input_data.get("command", "") + elif name == "Grep": + summary = input_data.get("pattern", "") + elif name == "Task": + summary = input_data.get("description", "") + elif name == "WebFetch": + summary = input_data.get("url", "") + elif name == "WebSearch": + summary = input_data.get("query", "") + elif name == "TodoWrite": + todos = input_data.get("todos", []) + if isinstance(todos, list): + summary = f"{len(todos)} item(s)" + elif name == "TodoRead": + summary = "" + elif name == "AskUserQuestion": + questions = input_data.get("questions", []) + if isinstance(questions, list) and questions: + q = questions[0] + if isinstance(q, dict): + summary = q.get("question", "") + elif name == "ExitPlanMode": + summary = "" + elif name == "Skill": + summary = input_data.get("skill", "") + else: + # Generic: show first string value + for v in input_data.values(): + if isinstance(v, str) and v: + summary = v + break + + if summary: + if len(summary) > cls._MAX_SUMMARY_LENGTH: + summary = summary[: cls._MAX_SUMMARY_LENGTH] + "…" + return f"**{name}**({summary})" + return f"**{name}**" + + @staticmethod + def extract_tool_result_text(content: list | Any) -> str: + """Extract text from a tool_result content block.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + t = item.get("text", "") + if t: + parts.append(t) + elif isinstance(item, str): + parts.append(item) + return "\n".join(parts) + return "" + + @staticmethod + def extract_tool_result_images( + content: list | Any, + ) -> list[tuple[str, bytes]] | None: + """Extract base64-encoded images from a tool_result content block. + + Returns list of (media_type, raw_bytes) tuples, or None if no images found. + """ + if not isinstance(content, list): + return None + images: list[tuple[str, bytes]] = [] + for item in content: + if not isinstance(item, dict) or item.get("type") != "image": + continue + source = item.get("source") + if not isinstance(source, dict) or source.get("type") != "base64": + continue + media_type = source.get("media_type", "image/png") + data_str = source.get("data", "") + if not data_str: + continue + try: + raw_bytes = base64.b64decode(data_str) + images.append((media_type, raw_bytes)) + except Exception: + logger.debug("Failed to decode base64 image in tool_result") + return images if images else None + + @classmethod + def parse_message(cls, data: dict) -> ParsedMessage | None: + """Parse a message entry from the JSONL data. + + Args: + data: Parsed JSON dict from a JSONL line + + Returns: + ParsedMessage or None if not a parseable message + """ + msg_type = cls.get_message_type(data) + + if msg_type not in ("user", "assistant"): + return None + + message = data.get("message") + if not isinstance(message, dict): + return None + content = message.get("content", "") + + if isinstance(content, list): + text = cls.extract_text_only(content) + else: + text = str(content) if content else "" + text = cls._RE_ANSI_ESCAPE.sub("", text) + + # Detect local command responses in user messages. + # These are rendered as bot replies: "❯ /cmd\n ⎿ output" + if msg_type == "user" and text: + stdout_match = cls._RE_LOCAL_STDOUT.search(text) + if stdout_match: + stdout = stdout_match.group(1).strip() + cmd_match = cls._RE_COMMAND_NAME.search(text) + cmd = cmd_match.group(1) if cmd_match else None + return ParsedMessage( + message_type="local_command", + text=stdout, + tool_name=cmd, # reuse field for command name + ) + # Pure command invocation (no stdout) — carry command name + cmd_match = cls._RE_COMMAND_NAME.search(text) + if cmd_match: + return ParsedMessage( + message_type="local_command_invoke", + text="", + tool_name=cmd_match.group(1), + ) + + return ParsedMessage( + message_type=msg_type, + text=text, + ) + + @staticmethod + def get_timestamp(data: dict) -> str | None: + """Extract timestamp from message data.""" + return data.get("timestamp") + + # No expandable quote sentinels — the React frontend handles collapsible rendering. + + @classmethod + def _format_collapsible(cls, text: str) -> str: + """Return text as-is. The frontend renders collapsible content.""" + return text + + @classmethod + def _format_tool_result_text(cls, text: str, tool_name: str | None = None) -> str: + """Format tool result text with statistics summary. + + Shows relevant statistics for each tool type, with expandable quote for full content. + + No truncation here — per project principles, truncation is handled + only at the send layer (split_message / _render_expandable_quote). + """ + if not text: + return "" + + line_count = text.count("\n") + 1 if text else 0 + + # Tool-specific statistics + if tool_name == "Read": + # Read: show line count instead of full content + return f" ⎿ Read {line_count} lines" + + elif tool_name == "Write": + # Write: show lines written + stats = f" ⎿ Wrote {line_count} lines" + return stats + + elif tool_name == "Bash": + # Bash: show output line count + if line_count > 0: + stats = f" ⎿ Output {line_count} lines" + return stats + "\n" + cls._format_collapsible(text) + return cls._format_collapsible(text) + + elif tool_name == "Grep": + # Grep: show match count (count non-empty lines) + matches = len([line for line in text.split("\n") if line.strip()]) + stats = f" ⎿ Found {matches} matches" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "Glob": + # Glob: show file count + files = len([line for line in text.split("\n") if line.strip()]) + stats = f" ⎿ Found {files} files" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "Task": + # Task: show output length + if line_count > 0: + stats = f" ⎿ Agent output {line_count} lines" + return stats + "\n" + cls._format_collapsible(text) + return cls._format_collapsible(text) + + elif tool_name == "WebFetch": + # WebFetch: show content length + char_count = len(text) + stats = f" ⎿ Fetched {char_count} characters" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "WebSearch": + # WebSearch: show results count (estimate by sections) + results = text.count("\n\n") + 1 if text else 0 + stats = f" ⎿ {results} search results" + return stats + "\n" + cls._format_collapsible(text) + + # Default: expandable quote without stats + return cls._format_collapsible(text) + + @classmethod + def parse_entries( + cls, + entries: list[dict], + pending_tools: dict[str, PendingToolInfo] | None = None, + ) -> tuple[list[ParsedEntry], dict[str, PendingToolInfo]]: + """Parse a list of JSONL entries into a flat list of display-ready messages. + + This is the shared core logic used by both get_recent_messages (history) + and check_for_updates (monitor). + + Args: + entries: List of parsed JSONL dicts (already filtered through parse_line) + pending_tools: Optional carry-over pending tool_use state from a + previous call (tool_use_id -> formatted summary). Used by the + monitor to handle tool_use and tool_result arriving in separate + poll cycles. + + Returns: + Tuple of (parsed entries, remaining pending_tools state) + """ + result: list[ParsedEntry] = [] + last_cmd_name: str | None = None + # Pending tool_use blocks keyed by id + _carry_over = pending_tools is not None + if pending_tools is None: + pending_tools = {} + else: + pending_tools = dict(pending_tools) # don't mutate caller's dict + + for data in entries: + msg_type = cls.get_message_type(data) + if msg_type not in ("user", "assistant"): + continue + + # Extract timestamp for this entry + entry_timestamp = cls.get_timestamp(data) + + message = data.get("message") + if not isinstance(message, dict): + continue + content = message.get("content", "") + if not isinstance(content, list): + content = [{"type": "text", "text": str(content)}] if content else [] + + parsed = cls.parse_message(data) + + # Handle local command messages first + if parsed: + if parsed.message_type == "local_command_invoke": + last_cmd_name = parsed.tool_name + continue + if parsed.message_type == "local_command": + cmd = parsed.tool_name or last_cmd_name or "" + text = parsed.text + if cmd: + if "\n" in text: + formatted = f"❯ `{cmd}`\n```\n{text}\n```" + else: + formatted = f"❯ `{cmd}`\n`{text}`" + else: + if "\n" in text: + formatted = f"```\n{text}\n```" + else: + formatted = f"`{text}`" + result.append( + ParsedEntry( + role="assistant", + text=formatted, + content_type="local_command", + timestamp=entry_timestamp, + ) + ) + last_cmd_name = None + continue + last_cmd_name = None + + if msg_type == "assistant": + # Pre-scan: check if this message contains an interactive + # tool_use (ExitPlanMode / AskUserQuestion). When present, + # suppress text entries from this same message — those text + # blocks are preamble that the terminal capture already + # includes. Emitting them as separate content messages + # causes a race: the content message clears the interactive + # UI state set by the status poller, leading to a duplicate + # interactive message being sent by the JSONL callable. + _INTERACTIVE_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"}) + has_interactive_tool = any( + isinstance(b, dict) + and b.get("type") == "tool_use" + and b.get("name") in _INTERACTIVE_TOOLS + for b in content + ) + + # Process content blocks + has_text = False + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type", "") + + if btype == "text": + # Skip text blocks when an interactive tool_use is + # present in the same message to avoid clearing the + # interactive UI state prematurely. + if has_interactive_tool: + continue + t = block.get("text", "").strip() + if t and t != cls._NO_CONTENT_PLACEHOLDER: + result.append( + ParsedEntry( + role="assistant", + text=t, + content_type="text", + timestamp=entry_timestamp, + ) + ) + has_text = True + + elif btype == "tool_use": + tool_id = block.get("id", "") + name = block.get("name", "unknown") + inp = block.get("input", {}) + summary = cls.format_tool_use_summary(name, inp) + + # ExitPlanMode: emit plan content as text before tool_use entry + if name == "ExitPlanMode" and isinstance(inp, dict): + plan = inp.get("plan", "") + if plan: + result.append( + ParsedEntry( + role="assistant", + text=plan, + content_type="text", + timestamp=entry_timestamp, + ) + ) + if tool_id: + # Store tool info for later tool_result formatting + # Edit tool needs input_data to generate diff in tool_result stage + input_data = ( + inp if name in ("Edit", "NotebookEdit") else None + ) + pending_tools[tool_id] = PendingToolInfo( + summary=summary, + tool_name=name, + input_data=input_data, + ) + # Also emit tool_use entry with tool_name for immediate handling + result.append( + ParsedEntry( + role="assistant", + text=summary, + content_type="tool_use", + tool_use_id=tool_id, + timestamp=entry_timestamp, + tool_name=name, + ) + ) + else: + result.append( + ParsedEntry( + role="assistant", + text=summary, + content_type="tool_use", + tool_use_id=tool_id or None, + timestamp=entry_timestamp, + tool_name=name, + ) + ) + + elif btype == "thinking": + thinking_text = block.get("thinking", "") + if thinking_text: + quoted = cls._format_collapsible(thinking_text) + result.append( + ParsedEntry( + role="assistant", + text=quoted, + content_type="thinking", + timestamp=entry_timestamp, + ) + ) + elif not has_text: + result.append( + ParsedEntry( + role="assistant", + text="(thinking)", + content_type="thinking", + timestamp=entry_timestamp, + ) + ) + + elif msg_type == "user": + # Check for tool_result blocks and merge with pending tools + user_text_parts: list[str] = [] + + for block in content: + if not isinstance(block, dict): + if isinstance(block, str) and block.strip(): + user_text_parts.append(block.strip()) + continue + btype = block.get("type", "") + + if btype == "tool_result": + tool_use_id = block.get("tool_use_id", "") + result_content = block.get("content", "") + result_text = cls.extract_tool_result_text(result_content) + result_images = cls.extract_tool_result_images(result_content) + is_error = block.get("is_error", False) + is_interrupted = result_text == cls._INTERRUPTED_TEXT + tool_info = pending_tools.pop(tool_use_id, None) + _tuid = tool_use_id or None + + # Extract tool info from PendingToolInfo object + if tool_info is None: + tool_summary = None + tool_name = None + tool_input_data = None + else: + tool_summary = tool_info.summary + tool_name = tool_info.tool_name + tool_input_data = tool_info.input_data + + if is_interrupted: + # Show interruption inline with tool summary + entry_text = tool_summary or "" + if entry_text: + entry_text += "\n⏹ Interrupted" + else: + entry_text = "⏹ Interrupted" + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + ) + ) + elif is_error: + # Show error in stats line + if tool_summary: + entry_text = tool_summary + else: + entry_text = "**Error**" + # Add error message in stats format + if result_text: + # Take first line of error as summary + error_summary = result_text.split("\n")[0] + if len(error_summary) > 100: + error_summary = error_summary[:100] + "…" + entry_text += f"\n ⎿ Error: {error_summary}" + # If multi-line error, add expandable quote + if "\n" in result_text: + entry_text += "\n" + cls._format_collapsible( + result_text + ) + else: + entry_text += "\n ⎿ Error" + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + elif tool_summary: + entry_text = tool_summary + # For Edit tool, generate diff stats and expandable quote + if tool_name == "Edit" and tool_input_data and result_text: + old_s = tool_input_data.get("old_string", "") + new_s = tool_input_data.get("new_string", "") + if old_s and new_s: + diff_text = cls._format_edit_diff(old_s, new_s) + if diff_text: + added = sum( + 1 + for line in diff_text.split("\n") + if line.startswith("+") + and not line.startswith("+++") + ) + removed = sum( + 1 + for line in diff_text.split("\n") + if line.startswith("-") + and not line.startswith("---") + ) + stats = f" ⎿ Added {added} lines, removed {removed} lines" + entry_text += ( + "\n" + + stats + + "\n" + + cls._format_collapsible(diff_text) + ) + # For other tools, append formatted result text + elif result_text: + entry_text += "\n" + cls._format_tool_result_text( + result_text, tool_name + ) + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + elif result_text or result_images: + result.append( + ParsedEntry( + role="assistant", + text=cls._format_tool_result_text( + result_text, tool_name + ) + if result_text + else (tool_summary or ""), + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + + elif btype == "text": + t = block.get("text", "").strip() + if t and not cls._RE_SYSTEM_TAGS.search(t): + user_text_parts.append(t) + + # Add user text if present (skip if message was only tool_results) + if user_text_parts: + combined = "\n".join(user_text_parts) + # Skip if it looks like local command XML + if not cls._RE_LOCAL_STDOUT.search( + combined + ) and not cls._RE_COMMAND_NAME.search(combined): + result.append( + ParsedEntry( + role="user", + text=combined, + content_type="text", + timestamp=entry_timestamp, + ) + ) + + # Flush remaining pending tools at end. + # In carry-over mode (monitor), keep them pending for the next call + # without emitting entries. In one-shot mode (history), emit them. + remaining_pending = dict(pending_tools) + if not _carry_over: + for tool_id, tool_info in pending_tools.items(): + result.append( + ParsedEntry( + role="assistant", + text=tool_info.summary, + content_type="tool_use", + tool_use_id=tool_id, + ) + ) + + # Strip whitespace + for entry in result: + entry.text = entry.text.strip() + + return result, remaining_pending diff --git a/ccweb/backend/core/utils.py b/ccweb/backend/core/utils.py new file mode 100644 index 00000000..13c94ec4 --- /dev/null +++ b/ccweb/backend/core/utils.py @@ -0,0 +1,69 @@ +"""Shared utility functions used across CCWeb modules. + +Provides: + - ccweb_dir(): resolve config directory from CCWEB_DIR env var. + - atomic_write_json(): crash-safe JSON file writes via temp+rename. + - read_cwd_from_jsonl(): extract the cwd field from the first JSONL entry. +""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +CCWEB_DIR_ENV = "CCWEB_DIR" + + +def ccweb_dir() -> Path: + """Resolve config directory from CCWEB_DIR env var or default ~/.ccweb.""" + raw = os.environ.get(CCWEB_DIR_ENV, "") + return Path(raw) if raw else Path.home() / ".ccweb" + + +def atomic_write_json(path: Path, data: Any, indent: int = 2) -> None: + """Write JSON data to a file atomically. + + Writes to a temporary file in the same directory, then renames it + to the target path. This prevents data corruption if the process + is interrupted mid-write. + """ + path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(data, indent=indent) + + # Write to temp file in same directory (same filesystem for atomic rename) + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), suffix=".tmp", prefix=f".{path.name}." + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(path)) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def read_cwd_from_jsonl(file_path: str | Path) -> str: + """Read the cwd field from the first JSONL entry that has one.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + cwd = data.get("cwd") + if cwd: + return cwd + except json.JSONDecodeError: + continue + except OSError: + pass + return "" diff --git a/ccweb/backend/main.py b/ccweb/backend/main.py new file mode 100644 index 00000000..275a11e3 --- /dev/null +++ b/ccweb/backend/main.py @@ -0,0 +1,197 @@ +"""CLI entry point for CCWeb. + +Provides three subcommands: + - ccweb: Start the web server (default) + - ccweb install: Install SessionStart hook + global Claude commands + - ccweb hook: Handle SessionStart hook (called by Claude Code) + +Key function: main(). +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + + +def _setup_logging() -> None: + """Configure logging for the application.""" + logging.basicConfig( + level=logging.INFO, + format=LOG_FORMAT, + ) + + +def _cmd_serve() -> None: + """Start the CCWeb server.""" + import uvicorn + + from .config import config + from .server import create_app + + app = create_app() + uvicorn.run( + app, + host=config.web_host, + port=config.web_port, + log_level="info", + ) + + +def _cmd_install() -> None: + """Install SessionStart hook and global Claude Code commands.""" + # Install hook + settings_path = Path.home() / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + pass + + hooks = settings.setdefault("hooks", {}) + session_start = hooks.setdefault("SessionStart", []) + + # Check if ccweb hook already exists + already_installed = False + for entry in session_start: + for hook in entry.get("hooks", []): + if "ccweb hook" in hook.get("command", ""): + already_installed = True + break + + if not already_installed: + session_start.append( + {"hooks": [{"type": "command", "command": "ccweb hook", "timeout": 5}]} + ) + settings_path.write_text(json.dumps(settings, indent=2)) + print(f"Installed SessionStart hook in {settings_path}") + else: + print("SessionStart hook already installed.") + + # Install global commands + commands_dir = Path.home() / ".claude" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + + commands = { + "option-grid.md": _OPTION_GRID_COMMAND, + "checklist.md": _CHECKLIST_COMMAND, + "status-report.md": _STATUS_REPORT_COMMAND, + "confirm.md": _CONFIRM_COMMAND, + } + + for filename, content in commands.items(): + path = commands_dir / filename + path.write_text(content) + print(f"Installed command: {path}") + + print("\nCCWeb installation complete!") + + +def _cmd_hook() -> None: + """Handle SessionStart hook — write session_map.json.""" + from .core.hook import hook_main + + hook_main() + + +# ── Global command templates ───────────────────────────────────────────── + +_OPTION_GRID_COMMAND = """\ +# Option Grid + +When called, research the topics provided and output an option grid for the user. + +You MUST do these two steps in sequence: + +Step 1: Write the grid as a JSON file using your Write tool. Create the directory +first if needed, then write to: {cwd}/.ccweb/pending/grid-{timestamp}.json + +Use this schema: +{"id": "unique-id", "type": "ccweb:grid", "title": "...", "items": [ + {"topic": "...", "description": "...", "allow_custom": true, + "options": [{"label": "...", "recommended": true}, ...]} +]} + +Each item must have: topic, description, options (array with recommended flag), +allow_custom: true. Always include 2-4 options per topic with one marked as +recommended. + +Step 2: IMMEDIATELY after writing the file, use AskUserQuestion to ask: +"I've prepared an option grid. Please review it in your CCWeb interface and +submit your selections. Your choices will appear here automatically." + +This is critical — AskUserQuestion blocks you until the user responds via CCWeb. +Do NOT proceed without waiting. The user's selections will be sent back as text. +""" + +_CHECKLIST_COMMAND = """\ +# Checklist + +When called, output an interactive checklist for the user. + +Write the checklist as a JSON file: + {cwd}/.ccweb/pending/checklist-{timestamp}.json + +Schema: {"type": "ccweb:checklist", "title": "...", "items": [ + {"label": "...", "checked": false}, ... +]} + +Then IMMEDIATELY use AskUserQuestion to wait for the user's response. +""" + +_STATUS_REPORT_COMMAND = """\ +# Status Report + +When called, output a status dashboard. This is read-only (no user response needed). + +Write the report as a JSON file: + {cwd}/.ccweb/pending/status-{timestamp}.json + +Schema: {"type": "ccweb:status", "title": "...", "items": [ + {"label": "...", "status": "pass|fail|warn", "detail": "..."}, ... +]} +""" + +_CONFIRM_COMMAND = """\ +# Confirm + +When called, present a confirmation dialog for a critical action. + +Write the dialog as a JSON file: + {cwd}/.ccweb/pending/confirm-{timestamp}.json + +Schema: {"type": "ccweb:confirm", "title": "...", + "description": "...", "severity": "high|medium|low", + "actions": [{"label": "...", "value": "..."}, ...]} + +Then IMMEDIATELY use AskUserQuestion to wait for the user's response. +""" + + +def main() -> None: + """CLI entry point.""" + _setup_logging() + + args = sys.argv[1:] + + if not args or args[0] not in ("install", "hook"): + _cmd_serve() + elif args[0] == "install": + _cmd_install() + elif args[0] == "hook": + _cmd_hook() + else: + print(f"Unknown command: {args[0]}") + print("Usage: ccweb [install | hook]") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ccweb/backend/server.py b/ccweb/backend/server.py new file mode 100644 index 00000000..e7c6759b --- /dev/null +++ b/ccweb/backend/server.py @@ -0,0 +1,471 @@ +"""FastAPI server — the web layer of CCWeb. + +Serves the React frontend (static files in production), provides REST +endpoints for session management, and a WebSocket endpoint for real-time +bidirectional communication with the browser. + +On startup: + 1. Run health checks (tmux running, hook installed, state dir exists) + 2. Initialize TmuxManager, SessionManager, SessionMonitor + 3. Start SessionMonitor polling loop + 4. Start status polling loop (1s interval) + 5. Set message callback to broadcast to connected WebSocket clients + +Key functions: create_app(), ws_endpoint(), handle_new_message(). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles + +from .config import config +from .core.session_monitor import NewMessage, SessionMonitor +from .core.terminal_parser import ( + extract_interactive_content, + is_interactive_ui, + parse_status_line, +) +from .core.tmux_manager import tmux_manager +from .ui_parser import parse_interactive_ui +from .ws_protocol import ( + CLIENT_CREATE_SESSION, + CLIENT_GET_HISTORY, + CLIENT_KILL_SESSION, + CLIENT_PING, + CLIENT_SEND_KEY, + CLIENT_SEND_TEXT, + CLIENT_SWITCH_SESSION, + WsError, + WsHealth, + WsInteractiveUI, + WsMessage, + WsSessions, + WsStatus, +) + +logger = logging.getLogger(__name__) + +# ── Connected clients ──────────────────────────────────────────────────── + +# client_id → WebSocket +_clients: dict[str, WebSocket] = {} +# client_id → window_id (which session the client is viewing) +_client_bindings: dict[str, str] = {} + +# Session monitor instance +_monitor: SessionMonitor | None = None +# Status polling task +_status_task: asyncio.Task[None] | None = None + + +# ── Health checks ──────────────────────────────────────────────────────── + + +async def _check_tmux_running() -> bool: + """Check if tmux server is running.""" + proc = await asyncio.create_subprocess_exec( + "tmux", + "list-sessions", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + return proc.returncode == 0 + + +def _check_hook_installed() -> bool: + """Check if the SessionStart hook is installed for ccweb.""" + settings_path = Path.home() / ".claude" / "settings.json" + if not settings_path.exists(): + return False + try: + data = json.loads(settings_path.read_text()) + hooks = data.get("hooks", {}).get("SessionStart", []) + for entry in hooks: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + if "ccweb hook" in cmd: + return True + except (json.JSONDecodeError, OSError): + pass + return False + + +async def _build_health() -> WsHealth: + """Build health check status.""" + tmux_running = await _check_tmux_running() + hook_installed = _check_hook_installed() + warnings: list[str] = [] + + if not tmux_running: + warnings.append( + "tmux is not running. Start tmux first: tmux new -s " + f"{config.tmux_session_name}" + ) + if not hook_installed: + warnings.append("SessionStart hook not installed. Run: ccweb install") + + windows = await tmux_manager.list_windows() if tmux_running else [] + + return WsHealth( + tmux_running=tmux_running, + hook_installed=hook_installed, + sessions_found=len(windows), + warnings=warnings, + ) + + +# ── Session list ───────────────────────────────────────────────────────── + + +async def _build_session_list() -> list[dict[str, Any]]: + """Build session list for the frontend.""" + windows = await tmux_manager.list_windows() + return [ + { + "window_id": w.window_id, + "name": w.window_name, + "cwd": w.cwd, + "command": w.pane_current_command, + } + for w in windows + ] + + +async def _broadcast_sessions() -> None: + """Send updated session list to all connected clients.""" + sessions = await _build_session_list() + msg = WsSessions(sessions=sessions).to_dict() + for ws in list(_clients.values()): + try: + await ws.send_json(msg) + except Exception: + pass + + +# ── Message callback (from SessionMonitor) ─────────────────────────────── + + +async def _handle_new_message(msg: NewMessage) -> None: + """Route a new message from SessionMonitor to bound WebSocket clients.""" + # Find which clients are bound to a window matching this session + from .session import session_manager + + for client_id, window_id in list(_client_bindings.items()): + ws = _clients.get(client_id) + if not ws: + continue + + # Check if this window's session matches the message's session + state = session_manager.get_window_state(window_id) + if state.session_id != msg.session_id: + continue + + ws_msg = WsMessage( + window_id=window_id, + role=msg.role, + content_type=msg.content_type, + text=msg.text, + tool_use_id=msg.tool_use_id, + tool_name=msg.tool_name, + ) + try: + await ws.send_json(ws_msg.to_dict()) + except Exception: + pass + + +# ── Status polling ─────────────────────────────────────────────────────── + + +async def _status_poll_loop() -> None: + """Poll terminal status for all active windows at 1-second intervals.""" + while True: + try: + for client_id, window_id in list(_client_bindings.items()): + ws = _clients.get(client_id) + if not ws: + continue + + w = await tmux_manager.find_window_by_id(window_id) + if not w: + continue + + pane_text = await tmux_manager.capture_pane(w.window_id) + if not pane_text: + continue + + # Check for interactive UI + if is_interactive_ui(pane_text): + content = extract_interactive_content(pane_text) + if content: + parsed = parse_interactive_ui(content.content, content.name) + ui_msg = WsInteractiveUI( + window_id=window_id, + ui_name=content.name, + raw_content=content.content, + structured=parsed.to_dict() if parsed else None, + ) + try: + await ws.send_json(ui_msg.to_dict()) + except Exception: + pass + continue + + # Check for status line + status_text = parse_status_line(pane_text) + if status_text: + status_msg = WsStatus( + window_id=window_id, + text=status_text, + ) + try: + await ws.send_json(status_msg.to_dict()) + except Exception: + pass + + except Exception as e: + logger.error("Status poll error: %s", e) + + await asyncio.sleep(1.0) + + +# ── WebSocket handler ──────────────────────────────────────────────────── + + +async def _handle_ws_message( + client_id: str, + ws: WebSocket, + data: dict[str, Any], +) -> None: + """Dispatch a single incoming WebSocket message.""" + msg_type = data.get("type", "") + window_id = data.get("window_id", "") + + if msg_type == CLIENT_PING: + await ws.send_json({"type": "pong"}) + return + + if msg_type == CLIENT_SWITCH_SESSION: + _client_bindings[client_id] = window_id + # Send history for the new session + from .session import session_manager + + messages, total = await session_manager.get_recent_messages(window_id) + await ws.send_json( + { + "type": "history", + "window_id": window_id, + "messages": messages, + "total": total, + } + ) + return + + if msg_type == CLIENT_SEND_TEXT: + text = data.get("text", "") + if text and window_id: + from .session import session_manager + + success, message = await session_manager.send_to_window(window_id, text) + if not success: + await ws.send_json( + WsError(code="send_failed", message=message).to_dict() + ) + return + + if msg_type == CLIENT_SEND_KEY: + key = data.get("key", "") + if key and window_id: + # Stale UI guard: verify interactive UI is still showing + w = await tmux_manager.find_window_by_id(window_id) + if w: + pane_text = await tmux_manager.capture_pane(w.window_id) + if pane_text and not is_interactive_ui(pane_text): + await ws.send_json( + WsError( + code="stale_ui", + message="This prompt has expired.", + ).to_dict() + ) + return + + # Map key names to tmux send_keys parameters + key_map: dict[str, tuple[str, bool, bool]] = { + "Enter": ("Enter", False, False), + "Escape": ("Escape", False, False), + "Space": ("Space", False, False), + "Tab": ("Tab", False, False), + "Up": ("Up", False, False), + "Down": ("Down", False, False), + "Left": ("Left", False, False), + "Right": ("Right", False, False), + } + key_info = key_map.get(key) + if key_info: + text_val, enter, literal = key_info + await tmux_manager.send_keys( + window_id, text_val, enter=enter, literal=literal + ) + return + + if msg_type == CLIENT_CREATE_SESSION: + work_dir = data.get("work_dir", "") + name = data.get("name") + if work_dir: + success, message, wname, wid = await tmux_manager.create_window( + work_dir, window_name=name + ) + if success: + _client_bindings[client_id] = wid + await _broadcast_sessions() + else: + await ws.send_json( + WsError(code="create_failed", message=message).to_dict() + ) + return + + if msg_type == CLIENT_KILL_SESSION: + if window_id: + await tmux_manager.kill_window(window_id) + # Unbind any clients from this window + for cid, wid in list(_client_bindings.items()): + if wid == window_id: + del _client_bindings[cid] + await _broadcast_sessions() + return + + if msg_type == CLIENT_GET_HISTORY: + if window_id: + from .session import session_manager + + messages, total = await session_manager.get_recent_messages(window_id) + await ws.send_json( + { + "type": "history", + "window_id": window_id, + "messages": messages, + "total": total, + } + ) + return + + logger.warning("Unknown WebSocket message type: %s", msg_type) + + +async def ws_endpoint(websocket: WebSocket) -> None: + """WebSocket connection handler.""" + await websocket.accept() + client_id = str(uuid.uuid4()) + _clients[client_id] = websocket + logger.info("WebSocket connected: %s", client_id) + + try: + # Send health check + health = await _build_health() + await websocket.send_json(health.to_dict()) + + # Send session list + sessions = await _build_session_list() + await websocket.send_json(WsSessions(sessions=sessions).to_dict()) + + # Message loop + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + await websocket.send_json( + WsError(code="invalid_json", message="Invalid JSON").to_dict() + ) + continue + + await _handle_ws_message(client_id, websocket, data) + + except WebSocketDisconnect: + logger.info("WebSocket disconnected: %s", client_id) + except Exception as e: + logger.error("WebSocket error for %s: %s", client_id, e) + finally: + _clients.pop(client_id, None) + _client_bindings.pop(client_id, None) + + +# ── App lifecycle ──────────────────────────────────────────────────────── + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """FastAPI lifespan: start monitor + status polling on startup.""" + global _monitor, _status_task + + # Startup health checks (log warnings, don't crash) + tmux_ok = await _check_tmux_running() + if not tmux_ok: + logger.warning( + "tmux is not running! Start tmux first: tmux new -s %s", + config.tmux_session_name, + ) + + hook_ok = _check_hook_installed() + if not hook_ok: + logger.warning("SessionStart hook not installed. Run: ccweb install") + + # Initialize session manager (loads state) + from .session import session_manager + + await session_manager.resolve_stale_ids() + await session_manager.load_session_map() + + # Start session monitor + _monitor = SessionMonitor() + _monitor.set_message_callback(_handle_new_message) + _monitor.start() + + # Start status polling + _status_task = asyncio.create_task(_status_poll_loop()) + + logger.info("CCWeb started on %s:%d", config.web_host, config.web_port) + + yield + + # Shutdown + if _status_task: + _status_task.cancel() + if _monitor: + _monitor.stop() + logger.info("CCWeb stopped") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI(title="CCWeb", lifespan=lifespan) + + # WebSocket endpoint + app.add_api_websocket_route("/ws", ws_endpoint) + + # REST endpoints + @app.get("/api/sessions") + async def list_sessions() -> list[dict[str, Any]]: + return await _build_session_list() + + @app.get("/api/health") + async def health() -> dict[str, Any]: + h = await _build_health() + return h.to_dict() + + # Serve static frontend files (production) + static_dir = Path(__file__).parent.parent / "frontend" / "dist" + if static_dir.exists(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True)) + + return app diff --git a/ccweb/backend/session.py b/ccweb/backend/session.py new file mode 100644 index 00000000..64543307 --- /dev/null +++ b/ccweb/backend/session.py @@ -0,0 +1,384 @@ +"""Claude Code session management — the core state hub for CCWeb. + +Simplified from ccbot's session.py: replaces Telegram thread_bindings +with a flat client_bindings model (client_id → window_id). Removes +group_chat_ids and all Telegram-specific routing. + +Manages the key mappings: + Window→Session (window_states): which Claude session_id a window holds. + Client→Window (client_bindings): which window a WebSocket client is viewing. + +Key class: SessionManager (singleton instantiated as `session_manager`). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiofiles + +from .config import config +from .core.tmux_manager import SHELL_COMMANDS, tmux_manager +from .core.transcript_parser import TranscriptParser +from .core.utils import atomic_write_json + +logger = logging.getLogger(__name__) + +# Patterns for detecting Claude Code resume commands in pane output +_RESUME_CMD_RE = re.compile(r"(claude\s+(?:--resume|-r)\s+\S+)") +_STOPPED_RE = re.compile(r"Stopped\s+.*claude", re.IGNORECASE) + + +def _extract_resume_command(pane_text: str) -> str | None: + """Extract a resume command from pane content after Claude Code exit.""" + if _STOPPED_RE.search(pane_text): + return "fg" + match = _RESUME_CMD_RE.search(pane_text) + if match: + return match.group(1) + return None + + +@dataclass +class WindowState: + """Persistent state for a tmux window.""" + + session_id: str = "" + cwd: str = "" + window_name: str = "" + + 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 + 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", ""), + ) + + +@dataclass +class ClaudeSession: + """Information about a Claude Code session.""" + + session_id: str + summary: str + message_count: int + file_path: str + + +@dataclass +class SessionManager: + """Manages session state for CCWeb. + + Simplified from ccbot: no thread_bindings, no group_chat_ids. + Client bindings are ephemeral (WebSocket connections) and not persisted. + """ + + window_states: dict[str, WindowState] = field(default_factory=dict) + window_display_names: dict[str, str] = field(default_factory=dict) + # Per-client read offsets (client_id → {window_id → byte_offset}) + # Not persisted — clients are ephemeral + user_window_offsets: dict[int, dict[str, int]] = field(default_factory=dict) + + def __post_init__(self) -> None: + self._load_state() + + def _save_state(self) -> None: + state: dict[str, Any] = { + "window_states": {k: v.to_dict() for k, v in self.window_states.items()}, + "window_display_names": self.window_display_names, + } + atomic_write_json(config.state_file, state) + logger.debug("State saved to %s", config.state_file) + + def _is_window_id(self, key: str) -> bool: + """Check if a key looks like a tmux window ID (e.g. '@0', '@12').""" + return key.startswith("@") and len(key) > 1 and key[1:].isdigit() + + def _load_state(self) -> None: + """Load state synchronously during initialization.""" + if config.state_file.exists(): + try: + state = json.loads(config.state_file.read_text()) + self.window_states = { + k: WindowState.from_dict(v) + for k, v in state.get("window_states", {}).items() + } + self.window_display_names = state.get("window_display_names", {}) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to load state: %s", e) + self.window_states = {} + self.window_display_names = {} + + async def resolve_stale_ids(self) -> None: + """Re-resolve persisted window IDs against live tmux windows.""" + windows = await tmux_manager.list_windows() + live_by_name: dict[str, str] = {} + live_ids: set[str] = set() + for w in windows: + live_by_name[w.window_name] = w.window_id + live_ids.add(w.window_id) + + changed = False + new_window_states: dict[str, WindowState] = {} + for key, ws in self.window_states.items(): + if self._is_window_id(key): + if key in live_ids: + new_window_states[key] = ws + else: + display = self.window_display_names.get(key, ws.window_name or key) + new_id = live_by_name.get(display) + if new_id: + logger.info( + "Re-resolved stale window_id %s -> %s (name=%s)", + key, + new_id, + display, + ) + new_window_states[new_id] = ws + ws.window_name = display + self.window_display_names[new_id] = display + changed = True + else: + logger.info("Dropping stale window_state: %s", key) + changed = True + else: + new_id = live_by_name.get(key) + if new_id: + logger.info("Migrating window_state key %s -> %s", key, new_id) + ws.window_name = key + new_window_states[new_id] = ws + self.window_display_names[new_id] = key + changed = True + else: + changed = True + + self.window_states = new_window_states + if changed: + self._save_state() + + async def load_session_map(self) -> None: + """Read session_map.json and update window_states.""" + if not config.session_map_file.exists(): + return + try: + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + except (json.JSONDecodeError, OSError): + return + + prefix = f"{config.tmux_session_name}:" + valid_wids: set[str] = set() + changed = False + + for key, info in session_map.items(): + if not key.startswith(prefix): + continue + window_id = key[len(prefix) :] + if not self._is_window_id(window_id): + continue + valid_wids.add(window_id) + new_sid = info.get("session_id", "") + new_cwd = info.get("cwd", "") + new_wname = info.get("window_name", "") + if not new_sid: + continue + state = self.get_window_state(window_id) + if state.session_id != new_sid or state.cwd != new_cwd: + state.session_id = new_sid + state.cwd = new_cwd + changed = True + if new_wname: + state.window_name = new_wname + if self.window_display_names.get(window_id) != new_wname: + self.window_display_names[window_id] = new_wname + changed = True + + stale_wids = [w for w in self.window_states if w and w not in valid_wids] + for wid in stale_wids: + del self.window_states[wid] + changed = True + + if changed: + self._save_state() + + # --- Display name management --- + + def get_display_name(self, window_id: str) -> str: + return self.window_display_names.get(window_id, window_id) + + # --- Window state management --- + + def get_window_state(self, window_id: str) -> WindowState: + if window_id not in self.window_states: + self.window_states[window_id] = WindowState() + return self.window_states[window_id] + + # --- Window → Session resolution --- + + def _build_session_file_path(self, session_id: str, cwd: str) -> Path | None: + if not session_id or not cwd: + return None + encoded_cwd = cwd.replace("/", "-") + return config.claude_projects_path / encoded_cwd / f"{session_id}.jsonl" + + async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | None: + """Resolve a tmux window to the best matching Claude session.""" + state = self.get_window_state(window_id) + if not state.session_id or not state.cwd: + return None + + file_path = self._build_session_file_path(state.session_id, state.cwd) + if not file_path or not file_path.exists(): + pattern = f"*/{state.session_id}.jsonl" + matches = list(config.claude_projects_path.glob(pattern)) + if matches: + file_path = matches[0] + else: + return None + + return ClaudeSession( + session_id=state.session_id, + summary="", + message_count=0, + file_path=str(file_path), + ) + + async def wait_for_session_map_entry( + self, window_id: str, timeout: float = 5.0, interval: float = 0.5 + ) -> bool: + """Poll session_map.json until an entry for window_id appears.""" + key = f"{config.tmux_session_name}:{window_id}" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + try: + if config.session_map_file.exists(): + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + sm = json.loads(content) + info = sm.get(key, {}) + if info.get("session_id"): + await self.load_session_map() + return True + except (json.JSONDecodeError, OSError): + pass + await asyncio.sleep(interval) + return False + + # --- Tmux helpers --- + + async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: + """Send text to a tmux window by ID, auto-resuming if needed.""" + display = self.get_display_name(window_id) + window = await tmux_manager.find_window_by_id(window_id) + if not window: + return False, "Window not found (may have been closed)" + if window.pane_current_command in SHELL_COMMANDS: + resumed = await self._try_resume_claude(window_id, display) + if not resumed: + return False, "Claude Code is not running (session exited)" + success = await tmux_manager.send_keys(window.window_id, text) + if success: + return True, f"Sent to {display}" + return False, "Failed to send keys" + + async def _try_resume_claude(self, window_id: str, display: str) -> bool: + """Attempt to resume Claude Code when pane has dropped to shell.""" + pane_text = await tmux_manager.capture_pane(window_id) + if not pane_text: + return False + resume_cmd = _extract_resume_command(pane_text) + if not resume_cmd: + return False + logger.info( + "Auto-resuming Claude in %s (%s): %s", window_id, display, resume_cmd + ) + await tmux_manager.send_keys(window_id, resume_cmd) + max_wait = 3.0 if resume_cmd == "fg" else 15.0 + elapsed = 0.0 + while elapsed < max_wait: + await asyncio.sleep(0.5) + elapsed += 0.5 + w = await tmux_manager.find_window_by_id(window_id) + if w and w.pane_current_command not in SHELL_COMMANDS: + await asyncio.sleep(1.0) + return True + return False + + # --- Message history --- + + async def get_recent_messages( + self, + window_id: str, + *, + start_byte: int = 0, + end_byte: int | None = None, + ) -> tuple[list[dict[str, Any]], int]: + """Get messages for a window's session.""" + session = await self.resolve_session_for_window(window_id) + if not session or not session.file_path: + return [], 0 + + file_path = Path(session.file_path) + if not file_path.exists(): + return [], 0 + + entries: list[dict[str, Any]] = [] + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + if start_byte > 0: + await f.seek(start_byte) + while True: + if end_byte is not None: + current_pos = await f.tell() + if current_pos >= end_byte: + break + line = await f.readline() + if not line: + break + data = TranscriptParser.parse_line(line) + if data: + entries.append(data) + except OSError as e: + logger.error("Error reading session file %s: %s", file_path, e) + return [], 0 + + parsed_entries, _ = TranscriptParser.parse_entries(entries) + all_messages = [ + { + "role": e.role, + "text": e.text, + "content_type": e.content_type, + "timestamp": e.timestamp, + } + for e in parsed_entries + ] + return all_messages, len(all_messages) + + async def find_clients_for_session( + self, + session_id: str, + ) -> list[str]: + """Find all window_ids whose session matches the given session_id.""" + result: list[str] = [] + for window_id, state in self.window_states.items(): + if state.session_id == session_id: + result.append(window_id) + return result + + +# Singleton +session_manager = SessionManager() diff --git a/ccweb/backend/ui_parser.py b/ccweb/backend/ui_parser.py new file mode 100644 index 00000000..811fc863 --- /dev/null +++ b/ccweb/backend/ui_parser.py @@ -0,0 +1,213 @@ +"""Structured UI parser — converts raw terminal text to interactive data. + +terminal_parser.py detects interactive UIs and returns raw text content. +This module takes that raw text and attempts to parse it into structured +data (option labels, action descriptions, etc.) that the React frontend +can render as clickable components. + +Parsing is fragile screen-scraping of Claude Code's terminal UI. The +parser is defensive: if parsing fails, it returns None and the frontend +falls back to displaying raw text with generic navigation buttons. + +Key function: parse_interactive_ui(). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ParsedOption: + """A single option in an interactive UI.""" + + label: str + checked: bool = False + index: int = 0 + + +@dataclass +class ParsedInteractiveUI: + """Structured data extracted from an interactive terminal UI.""" + + ui_name: str + options: list[ParsedOption] = field(default_factory=list) + description: str = "" + command: str = "" # For BashApproval + + def to_dict(self) -> dict[str, Any]: + return { + "ui_name": self.ui_name, + "options": [ + {"label": o.label, "checked": o.checked, "index": o.index} + for o in self.options + ], + "description": self.description, + "command": self.command, + } + + +# Checkbox markers used by Claude Code's AskUserQuestion UI +_RE_CHECKBOX = re.compile(r"^\s*(?:←\s+)?([☐✔☒])\s+(.+)$") + +# Permission prompt action line +_RE_PERMISSION_ACTION = re.compile( + r"^\s*Do you want to (proceed|make this edit|create|delete)\b" +) + +# Numbered choice (e.g., "❯ 1. Yes, allow once") +_RE_NUMBERED_CHOICE = re.compile(r"^\s*[❯ ]\s*(\d+)\.\s+(.+)$") + +# Bash command line +_RE_BASH_COMMAND = re.compile(r"^\s*(.+)$") + + +def parse_interactive_ui( + raw_content: str, + ui_name: str, +) -> ParsedInteractiveUI | None: + """Parse raw terminal text into structured interactive UI data. + + Args: + raw_content: The raw text content from terminal_parser + ui_name: The UI type name ("AskUserQuestion", "PermissionPrompt", etc.) + + Returns: + Parsed structure or None if parsing fails (frontend uses raw text fallback) + """ + if not raw_content or not ui_name: + return None + + lines = raw_content.strip().split("\n") + + if ui_name == "AskUserQuestion": + return _parse_ask_user_question(lines) + if ui_name == "ExitPlanMode": + return _parse_exit_plan_mode(lines) + if ui_name in ("PermissionPrompt", "BashApproval"): + return _parse_permission_prompt(lines, ui_name) + if ui_name == "RestoreCheckpoint": + return _parse_restore_checkpoint(lines) + + return None + + +def _parse_ask_user_question(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse AskUserQuestion: extract checkbox options.""" + options: list[ParsedOption] = [] + idx = 0 + for line in lines: + match = _RE_CHECKBOX.match(line) + if match: + marker = match.group(1) + label = match.group(2).strip() + checked = marker in ("✔", "☒") + options.append(ParsedOption(label=label, checked=checked, index=idx)) + idx += 1 + + if not options: + return None + + return ParsedInteractiveUI(ui_name="AskUserQuestion", options=options) + + +def _parse_exit_plan_mode(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse ExitPlanMode: extract proceed/edit options.""" + description_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if ( + stripped + and not stripped.startswith("ctrl-g") + and not stripped.startswith("Esc") + ): + description_lines.append(stripped) + + return ParsedInteractiveUI( + ui_name="ExitPlanMode", + description="\n".join(description_lines), + options=[ + ParsedOption(label="Proceed", index=0), + ParsedOption(label="Edit Plan", index=1), + ], + ) + + +def _parse_permission_prompt( + lines: list[str], ui_name: str +) -> ParsedInteractiveUI | None: + """Parse PermissionPrompt or BashApproval.""" + description_lines: list[str] = [] + command = "" + options: list[ParsedOption] = [] + idx = 0 + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Check for numbered choices + num_match = _RE_NUMBERED_CHOICE.match(line) + if num_match: + label = num_match.group(2).strip() + options.append(ParsedOption(label=label, index=idx)) + idx += 1 + continue + + # Skip footer lines + if stripped.startswith("Esc to"): + continue + + description_lines.append(stripped) + + description = "\n".join(description_lines) + + # If no numbered choices found, provide default Allow/Deny + if not options: + options = [ + ParsedOption(label="Allow", index=0), + ParsedOption(label="Deny", index=1), + ] + + # For BashApproval, try to extract the command + if ui_name == "BashApproval" and len(description_lines) > 1: + # The command is typically the line after "Bash command" + for i, line in enumerate(description_lines): + if "bash command" in line.lower() or "requires approval" in line.lower(): + if i + 1 < len(description_lines): + command = description_lines[i + 1] + break + + return ParsedInteractiveUI( + ui_name=ui_name, + description=description, + options=options, + command=command, + ) + + +def _parse_restore_checkpoint(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse RestoreCheckpoint: extract checkpoint options.""" + options: list[ParsedOption] = [] + idx = 0 + for line in lines: + stripped = line.strip() + if ( + not stripped + or stripped.startswith("Enter to") + or stripped.startswith("Esc") + ): + continue + options.append(ParsedOption(label=stripped, index=idx)) + idx += 1 + + if not options: + return None + + return ParsedInteractiveUI( + ui_name="RestoreCheckpoint", + options=options, + ) diff --git a/ccweb/backend/ws_protocol.py b/ccweb/backend/ws_protocol.py new file mode 100644 index 00000000..04c2163a --- /dev/null +++ b/ccweb/backend/ws_protocol.py @@ -0,0 +1,135 @@ +"""WebSocket protocol message types for CCWeb. + +Defines the bidirectional JSON message format between the FastAPI backend +and the React frontend. Server→Client messages deliver Claude output, +interactive UIs, status updates, and session lists. Client→Server messages +send user text, key presses, decision grid submissions, and session commands. + +All messages are JSON objects with a "type" field for dispatch. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +# ── Server → Client messages ───────────────────────────────────────────── + + +@dataclass +class WsMessage: + """A new message from a Claude Code session.""" + + type: str = "message" + window_id: str = "" + role: str = "assistant" + content_type: str = "text" + text: str = "" + tool_use_id: str | None = None + tool_name: str | None = None + timestamp: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsInteractiveUI: + """An interactive UI detected in the terminal (AskUserQuestion, etc.).""" + + type: str = "interactive_ui" + window_id: str = "" + ui_name: str = "" + raw_content: str = "" # Always included as fallback + structured: dict[str, Any] | None = None # Parsed options when available + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsDecisionGrid: + """A decision grid file detected in .ccweb/pending/.""" + + type: str = "decision_grid" + window_id: str = "" + grid: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsStatus: + """Status update (spinner text from Claude's status line).""" + + type: str = "status" + window_id: str = "" + text: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsSessions: + """Current session list broadcast.""" + + type: str = "sessions" + sessions: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsHealth: + """Health check sent on WebSocket connect.""" + + type: str = "health" + tmux_running: bool = False + hook_installed: bool = False + sessions_found: int = 0 + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsError: + """Backend error notification.""" + + type: str = "error" + code: str = "" + message: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsReplay: + """Message replay after client reconnection.""" + + type: str = "replay" + messages: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +# ── Client → Server messages (parsed from JSON dicts) ──────────────────── + +# These are not dataclasses — they're parsed from incoming JSON. +# Type field values for dispatch: +CLIENT_SEND_TEXT = "send_text" +CLIENT_SEND_KEY = "send_key" +CLIENT_SUBMIT_DECISIONS = "submit_decisions" +CLIENT_CREATE_SESSION = "create_session" +CLIENT_KILL_SESSION = "kill_session" +CLIENT_SWITCH_SESSION = "switch_session" +CLIENT_GET_HISTORY = "get_history" +CLIENT_REPLAY_REQUEST = "replay_request" +CLIENT_PING = "ping" diff --git a/ccweb/docs/architecture/design-plan.md b/ccweb/docs/architecture/design-plan.md new file mode 100644 index 00000000..28075d9d --- /dev/null +++ b/ccweb/docs/architecture/design-plan.md @@ -0,0 +1,843 @@ +# CCWeb: React Web Gateway to Claude Code + +## Context + +CCBot currently bridges Telegram Forum topics to Claude Code sessions via tmux. The user wants a **richer web-based interface** that replaces Telegram entirely, with: +- A styled message stream (not raw terminal) with text input for sending messages/commands +- Interactive components: when Claude asks questions (AskUserQuestion, permission prompts), render them as clickable HTML instead of terminal navigation +- A **decision grid** system: a Claude Code skill outputs structured options, CCWeb renders them as an interactive HTML grid, user clicks selections, answers sent back to Claude Code +- Session management (create, switch, kill) similar to Telegram topics + +The existing ccbot backend (tmux management, session monitoring, terminal parsing, JSONL parsing, hook system) is transport-agnostic and highly reusable. The Telegram-specific layer (bot.py, handlers/, markdown_v2.py, telegram_sender.py) gets replaced. + +## User Workflow + +**Current (ccbot + Telegram):** +``` +WSL → tmux → run `ccbot` → interact via Telegram app +``` + +**New (ccweb + Browser):** +``` +WSL → tmux → run `ccweb` → interact via browser at http://:8765 +``` + +Everything else stays the same: Claude Code still runs in tmux windows, the SessionStart hook still writes session_map.json, the monitor still polls JSONL files. Only the UI layer changes. + +Both can coexist: run `ccbot` AND `ccweb` simultaneously, both connecting to the same tmux sessions. + +## Interactive UI in the Browser + +All interactive Claude Code UIs currently handled by ccbot's terminal-navigation keyboard will be rendered as proper HTML components: + +| Claude Code UI | Current (Telegram) | CCWeb (Browser) | +|---|---|---| +| ExitPlanMode | Arrow-key navigation | "Proceed" / "Edit Plan" buttons, plan rendered as markdown | +| AskUserQuestion | Arrow-key checkboxes | Clickable option cards, click to select + Submit | +| Permission Prompt | Arrow-key Yes/No | "Allow" / "Deny" buttons with action description | +| Bash Approval | Arrow-key approve | "Run" / "Deny" with command in code block | +| Settings/Model | Arrow-key menu | Dropdown or card selector | + +Detection uses the same `terminal_parser.py` from ccbot — `extract_interactive_content()` identifies the UI type and content, then the backend sends structured data to the frontend via WebSocket, and the frontend renders the appropriate component. When the user clicks, the frontend sends the corresponding key (Enter, Escape, Space, Tab, arrows) back to tmux. + +## Design Constraints + +- **Single user**: This is a single-user application. No multi-user auth, no per-user state separation. The one user who runs `ccweb` is the only user. +- **Single browser session at a time**: While multiple tabs technically work (fan-out from SessionMonitor callback), interactive UI responses are not tab-locked — last click wins. This is acceptable for single-user. + +## Architecture Overview + +``` +ccweb/ +├── backend/ # Python (FastAPI + WebSocket) +│ ├── core/ # Copied + adapted from ccbot (transport-agnostic) +│ │ ├── tmux_manager.py ← copy verbatim +│ │ ├── terminal_parser.py ← copy verbatim +│ │ ├── transcript_parser.py ← adapt (remove Telegram expandable quote sentinels) +│ │ ├── session_monitor.py ← copy verbatim +│ │ ├── monitor_state.py ← copy verbatim +│ │ ├── hook.py ← copy verbatim +│ │ └── utils.py ← copy verbatim +│ ├── config.py # New: web-specific config (port, auth, no Telegram) +│ ├── session.py # Adapted: simplified bindings (client_id → window_id) +│ ├── server.py # New: FastAPI app, WebSocket handler, REST endpoints +│ ├── ws_protocol.py # New: WebSocket message types and serialization +│ └── main.py # New: CLI entry point +├── frontend/ # React + TypeScript (Vite) +│ ├── src/ +│ │ ├── App.tsx +│ │ ├── components/ +│ │ │ ├── MessageStream.tsx # Styled message display +│ │ │ ├── MessageInput.tsx # Text input + command sending +│ │ │ ├── SessionSidebar.tsx # Session list + create/switch/kill +│ │ │ ├── InteractiveUI.tsx # AskUserQuestion, permissions, etc. +│ │ │ ├── DecisionGrid.tsx # Custom option grid rendering +│ │ │ ├── StatusBar.tsx # Claude status (spinner, working text) +│ │ │ └── ExpandableBlock.tsx # Thinking, tool results (click to expand) +│ │ ├── hooks/ +│ │ │ ├── useWebSocket.ts # WebSocket connection + reconnect +│ │ │ └── useSession.ts # Session state management +│ │ ├── types.ts # Shared TypeScript types +│ │ └── protocol.ts # WebSocket message types (mirrors ws_protocol.py) +│ └── package.json +├── pyproject.toml # Python package config +└── README.md +``` + +## Module Reuse Map + +### Forked Modules (NOT verbatim copies — all need adaptation) +Every ccbot module imports `from .config import config` and `from .utils import ccbot_dir`. These are **forks**, not copies. Each needs: +- Import paths updated (now under `ccweb.backend.core`) +- `ccbot_dir()` → `ccweb_dir()` (returns `~/.ccweb/` or `$CCWEB_DIR`) +- Config interface matched to the new ccweb config singleton + +| Source | Destination | Changes Required | +|--------|------------|-----------------| +| `src/ccbot/tmux_manager.py` | `ccweb/backend/core/tmux_manager.py` | Update config import. Uses `config.tmux_session_name`, `config.claude_command`, `config.tmux_main_window_name` — new config must expose same attrs. | +| `src/ccbot/terminal_parser.py` | `ccweb/backend/core/terminal_parser.py` | Zero config deps — this one IS a true copy. Pure regex, no imports from config/utils. | +| `src/ccbot/session_monitor.py` | `ccweb/backend/core/session_monitor.py` | Update config import. Uses `config.claude_projects_path`, `config.monitor_poll_interval`, `config.session_map_file`, `config.show_user_messages`. | +| `src/ccbot/monitor_state.py` | `ccweb/backend/core/monitor_state.py` | Update utils import (`atomic_write_json`). Otherwise clean. | +| `src/ccbot/hook.py` | `ccweb/backend/core/hook.py` | Update to write to `~/.ccweb/session_map.json` (via `ccweb_dir()`). Hook command becomes `ccweb hook` instead of `ccbot hook`. | +| `src/ccbot/utils.py` | `ccweb/backend/core/utils.py` | Rename `ccbot_dir()` → `ccweb_dir()`, default `~/.ccweb/`, env var `CCWEB_DIR`. | +| `src/ccbot/transcript_parser.py` | `ccweb/backend/core/transcript_parser.py` | Remove `EXPANDABLE_QUOTE_START/END` sentinels. Keep all JSONL parsing logic. | +| `src/ccbot/config.py` | `ccweb/backend/config.py` | Full rewrite. Remove Telegram vars. Add web vars. Must expose same attribute names used by forked modules: `tmux_session_name`, `tmux_main_window_name`, `claude_command`, `claude_projects_path`, `monitor_poll_interval`, `state_file`, `session_map_file`, `monitor_state_file`, `show_user_messages`, `show_hidden_dirs`, `browse_root`, `memory_monitor_enabled`, etc. | +| `src/ccbot/session.py` | `ccweb/backend/session.py` | Replace `thread_bindings` with `client_bindings` (client_id → window_id). Remove group_chat_ids. Keep window_states, user_window_offsets, load_session_map, resolve_stale_ids, send_to_window, get_recent_messages. | + +### Drop (Telegram-only, replaced by web equivalents) +- `src/ccbot/bot.py` → replaced by `server.py` +- `src/ccbot/handlers/` → replaced by WebSocket message handlers in `server.py` +- `src/ccbot/markdown_v2.py` → React renders markdown natively +- `src/ccbot/telegram_sender.py` → WebSocket sends (no 4096 char limit) +- `src/ccbot/screenshot.py` → optional; styled view replaces primary use + +## WebSocket Protocol (`ws_protocol.py` / `protocol.ts`) + +Bidirectional JSON messages over WebSocket: + +### Server → Client +``` +# New message from Claude (text, thinking, tool_use, tool_result) +{"type": "message", "window_id": "@0", "role": "assistant", "content_type": "text", + "text": "...", "tool_use_id": null, "tool_name": null, "timestamp": "..."} + +# Interactive UI detected (AskUserQuestion, permission, etc.) +{"type": "interactive_ui", "window_id": "@0", "ui_name": "AskUserQuestion", + "content": "...", "options": [...]} # parsed from terminal capture + +# Decision grid (custom skill output) +{"type": "decision_grid", "window_id": "@0", "grid": {...}} + +# Status update (spinner, working text) +{"type": "status", "window_id": "@0", "text": "Reading files..."} + +# Session list update +{"type": "sessions", "sessions": [{"window_id": "@0", "name": "project", "cwd": "/path"}]} +``` + +### Client → Server +``` +# Send text to Claude (like typing in terminal/Telegram) +{"type": "send_text", "window_id": "@0", "text": "Fix the bug in auth.py"} + +# Send key press (for interactive UI navigation) +{"type": "send_key", "window_id": "@0", "key": "Enter"} + +# Submit decision grid selections (each item has selected option + optional notes) +{"type": "submit_decisions", "window_id": "@0", "selections": [ + {"topic": "Auth module refactor", "choice": "Update to bcrypt v4 API", "notes": ""}, + {"topic": "Error handling", "choice": "Add retry with backoff", "notes": "Handle 429 specifically"}, + {"topic": "Logging", "choice": null, "notes": "Let's discuss structured logging first"} +]} + +# Session management +{"type": "create_session", "work_dir": "/path/to/project", "name": "my-project"} +{"type": "kill_session", "window_id": "@0"} +{"type": "switch_session", "window_id": "@0"} + +# Request history +{"type": "get_history", "window_id": "@0", "page": 0} +``` + +## Decision Grid Protocol + +For the user's custom skill that generates option grids: + +1. **Detection**: The skill writes a JSON file to `{session_cwd}/.ccweb/decisions/{id}.json` +2. **Backend monitors** this directory (or the skill outputs a marker in JSONL text) +3. **Grid JSON schema**: +```json +{ + "id": "decision-001", + "title": "Code Review Decisions", + "items": [ + { + "topic": "Auth module refactor", + "description": "The auth module uses deprecated bcrypt API...", + "options": [ + {"label": "Update to bcrypt v4 API", "recommended": true}, + {"label": "Switch to argon2", "recommended": false}, + {"label": "Keep current (suppress warning)", "recommended": false} + ], + "allow_custom": true + } + ] +} +``` +4. **Frontend** renders this as an interactive HTML table/card grid: + - Each row: topic name | description | option radio buttons/cards | **notes column** + - The **notes column** is always present on every row — a text input that lets the user: + - Add additional context/details to accompany their selection + - Provide a custom option not listed (override) + - Ask a question or request more info on that specific topic + - Recommended option is pre-selected/highlighted but user can change + - "Submit All" button at bottom +5. **On submit**: client sends `{"type": "submit_decisions", ...}` with selections AND notes, backend formats as text and sends to Claude via `tmux_manager.send_keys()` +6. The text sent to Claude would be formatted like: +``` +Decisions for "Code Review Decisions": +- Auth module refactor: Update to bcrypt v4 API +- Error handling: Add retry with backoff + Note: "Make sure we handle the 429 case specifically" +- Logging strategy: [Custom] "Let's discuss this one more - what about structured logging?" +... +``` + +## Custom Interaction Types (CCWeb Protocol) + +Beyond the decision grid, define additional structured interaction types that Claude Code skills can output. All use the same detection mechanism (marker in JSONL text or file in `.ccweb/`): + +### 1. Decision Grid (primary use case) +See above. Skill outputs structured JSON, user selects options + adds notes, answers sent back. + +### 2. Checklist +Simpler than a decision grid — just checkboxes with labels. User checks items and submits. +```json +{"type": "ccweb:checklist", "title": "Pre-deploy checks", "items": [ + {"label": "Tests passing", "checked": false}, + {"label": "Migrations reviewed", "checked": false}, + {"label": "ENV vars updated", "checked": true} +]} +``` +Submitted as: "Checked: Tests passing, Migrations reviewed. Unchecked: ENV vars updated." + +### 3. Status Report +A skill outputs a structured status dashboard. Read-only (no user interaction needed). +```json +{"type": "ccweb:status", "title": "Build Status", "items": [ + {"label": "Unit tests", "status": "pass", "detail": "142/142"}, + {"label": "Lint", "status": "fail", "detail": "3 errors in auth.py"}, + {"label": "Type check", "status": "pass", "detail": "0 errors"} +]} +``` +Rendered as cards with green/yellow/red indicators. + +### 4. Confirmation Dialog +For critical actions that need explicit user approval with context. +```json +{"type": "ccweb:confirm", "title": "Deploy to production?", + "description": "This will deploy commit abc123 to prod. 3 migrations pending.", + "severity": "high", + "actions": [{"label": "Deploy", "value": "yes"}, {"label": "Cancel", "value": "no"}]} +``` + +### Detection Mechanism (file-based, primary) +The Claude Code skill writes a JSON file to a well-known location using the Write tool (which is atomic and reliable — no risk of malformed output or marker splitting): + +``` +{session_cwd}/.ccweb/pending/{type}-{timestamp}.json +``` + +Example: `.ccweb/pending/grid-1712345678.json` + +The backend watches this directory (via polling alongside JSONL monitoring). When a new file appears: +1. Read and validate the JSON +2. Send structured WebSocket message to the frontend +3. Move file to `.ccweb/completed/` after user submits (or `.ccweb/dismissed/` if ignored) + +**Why file-based instead of text markers**: Writing a file via Claude's Write tool is a deterministic tool call — always produces valid JSON, no streaming chunk splits, no code-block false positives, easy to debug (`cat` the file). Text markers (``) are fragile because Claude's output is free-form text that can be malformed, wrapped in code blocks, or split across JSONL entries. + +**CRITICAL: Timing — how Claude waits for the user's response**: +The skill MUST use `AskUserQuestion` after writing the grid file. Without this, Claude writes the file and immediately proceeds — by the time the user sees the grid, Claude is 3 tool calls ahead, and the submitted text arrives as garbage input mid-thought. + +Correct flow: +1. Skill writes grid JSON to `.ccweb/pending/grid-xxx.json` (via Write tool) +2. Skill IMMEDIATELY calls `AskUserQuestion` with: "I've prepared an option grid for you. Please review it in your CCWeb interface and submit your selections. (Your answers will appear here automatically.)" +3. `AskUserQuestion` **blocks Claude** — it waits for user input +4. Backend detects the grid file → sends to frontend via WebSocket +5. User fills out grid, clicks Submit +6. Backend sends formatted selection text to Claude via tmux keystrokes +7. Claude receives the text as the AskUserQuestion response, unblocks, and continues + +This is the ONLY correct approach. The skill prompt must enforce steps 1+2 together. + +### CCWeb Skill Installation +Skills are installed **globally** in `~/.claude/commands/` so they're available in every project. No per-repo copying needed. + +**`ccweb install`** command auto-installs: +1. The SessionStart hook (same as ccbot's `ccbot hook --install`) +2. Global slash commands for all ccweb interaction types: + - `~/.claude/commands/option-grid.md` — Decision/option grid + - `~/.claude/commands/checklist.md` — Interactive checklist + - `~/.claude/commands/status-report.md` — Status dashboard + - `~/.claude/commands/confirm.md` — Confirmation dialog + +**Example: `~/.claude/commands/option-grid.md`:** +```markdown +# Option Grid + +When called, research the topics provided and output an option grid for the user. + +You MUST do these two steps in sequence: + +Step 1: Write the grid as a JSON file using your Write tool to this exact path: + {cwd}/.ccweb/pending/grid-{timestamp}.json + +Use this schema: +{"id": "unique-id", "type": "ccweb:grid", "title": "...", "items": [ + {"topic": "...", "description": "...", "allow_custom": true, + "options": [{"label": "...", "recommended": true}, ...]} +]} + +Each item must have: topic, description, options (array with recommended flag), +allow_custom: true. Always include 2-4 options per topic with one marked as recommended. + +Step 2: IMMEDIATELY after writing the file, use AskUserQuestion to ask: +"I've prepared an option grid. Please review it in your CCWeb interface and submit +your selections. Your choices will appear here automatically." + +This is critical — AskUserQuestion blocks you until the user responds via CCWeb. +Do NOT proceed without waiting. The user's selections will be sent back as text. +``` + +Run `ccweb install` once and all commands are available everywhere. + +## Implementation Order + +### Phase 1: Project Scaffold + Core Backend +1. Create `ccweb/` directory structure +2. Create `pyproject.toml` with deps: `fastapi`, `uvicorn[standard]`, `websockets`, `libtmux`, `aiofiles`, `python-dotenv` — pin same `libtmux` version as ccbot to avoid API breakage +3. **Create `config.py` FIRST** — this is the foundation. Must expose all attribute names used by forked modules (`tmux_session_name`, `claude_projects_path`, `state_file`, `session_map_file`, etc.) without Telegram deps +4. **Create `utils.py` SECOND** — `ccweb_dir()` returning `~/.ccweb/`, `atomic_write_json()` +5. Fork modules from ccbot to `ccweb/backend/core/`: update all `from .config import config` and `from .utils import` paths. `terminal_parser.py` is the only true copy (zero config/utils deps). +6. Adapt `transcript_parser.py` (remove Telegram expandable quote sentinels) +7. Adapt `hook.py` — writes to `~/.ccweb/session_map.json`, command is `ccweb hook` +8. Create simplified `session.py` (client_bindings instead of thread_bindings) +9. Create `ws_protocol.py` (message type definitions) +10. Create `server.py` (FastAPI app, WebSocket handler, REST endpoints) +11. Create `main.py` (CLI entry with `ccweb`, `ccweb install`, `ccweb hook` subcommands) +12. **Verify**: run `ccweb` — FastAPI starts, WebSocket connects, health check passes + +### Phase 2: React Frontend Scaffold +1. `npm create vite@latest frontend -- --template react-ts` +2. Install deps: `react-markdown`, `remark-gfm` (markdown rendering), a CSS framework (Tailwind or similar) +3. Create `protocol.ts` (mirrors WebSocket types) +4. Create `useWebSocket.ts` hook (connect, reconnect, message dispatch) +5. Create basic `App.tsx` layout (sidebar + main content area) + +### Phase 3: Message Stream + Input +1. `MessageStream.tsx` - renders messages with role-based styling: + - Assistant text → markdown rendered + - Thinking → collapsible/expandable block + - tool_use → summary line (like "**Read**(file.py)") + - tool_result → `
` block (collapsible). Rich rendering (diff viewer, search cards) deferred to v2
+   - User messages → right-aligned or prefixed
+2. `FileUpload.tsx` - document/file upload:
+   - Paperclip (📎) button next to the text input
+   - Accepts: text files, code, Markdown, PDF, Word docs, images (same as ccbot's allowed types)
+   - Upload flow: file sent to backend → saved to `{session_cwd}/docs/inbox/{filename}` → path sent to Claude via tmux as "A file has been saved to docs/inbox/{name}. Read it with your Read tool."
+   - Optional caption/instruction text alongside the upload
+   - Drag-and-drop support on the message area
+   - Image files rendered as inline thumbnails before sending
+3. `MessageInput.tsx` - multi-line text area with:
+   - **Enter = newline** (multi-line input by default)
+   - **Visible Submit button** to send the message
+   - **Ctrl+Enter / Cmd+Enter** keyboard shortcut to submit (optional accelerator)
+   - `/command` forwarding (auto-complete dropdown on `/`)
+   - Command history (Ctrl+Up / Ctrl+Down to cycle through previous messages)
+3. `StatusBar.tsx` - shows Claude's current status (spinner text)
+
+**Note on latency**: JSONL polling at 2s means responses appear in bursts, not token-by-token streaming. The status bar (polled at 1s) provides "Claude is working..." feedback during the gap. This matches ccbot's current latency. True streaming would require monitoring the JSONL file via inotify/watchdog instead of polling — a v2 enhancement.
+
+### Phase 4: Session Management
+The session sidebar replaces Telegram's topic list:
+1. `SessionSidebar.tsx`:
+   - List active sessions (window_id, name, cwd, status indicator)
+   - Click to switch (binds WebSocket to that window, loads history)
+   - **"+ New Session" button** → opens directory picker modal
+   - Kill session: X button on each session (confirms first)
+   - Session status: idle / working / waiting for input (based on status polling)
+2. `DirectoryPicker.tsx` (modal):
+   - File-tree browser (reuses ccbot's directory browsing logic on backend)
+   - Click folders to navigate, "Select" to confirm
+   - Path text input for direct entry
+   - Recent directories list (persisted in localStorage)
+   - Optional session name field
+3. Backend:
+   - `POST /api/sessions` → create_window + start Claude + wait for session_map
+   - `GET /api/sessions` → list active sessions
+   - `DELETE /api/sessions/{window_id}` → kill_window + cleanup
+   - WebSocket broadcasts session list changes to all connected clients
+
+### Phase 5: Interactive UI Components
+1. **Backend: structured UI parser** (`ui_parser.py`):
+   - `terminal_parser.py` returns raw text (`InteractiveUIContent.content` is a string with Unicode checkboxes like `☐✔☒`, cursor markers, etc.)
+   - New `ui_parser.py` module parses this raw text into structured data:
+     - AskUserQuestion: extract option labels + checked/unchecked state from `☐`/`✔`/`☒` markers
+     - PermissionPrompt: extract the action description + yes/no options
+     - ExitPlanMode: extract plan summary text
+     - BashApproval: extract the bash command being requested
+   - This is **fragile screen-scraping** — Claude Code can change its terminal UI format. The parser must be defensive with fallback to raw text display.
+   - WebSocket message includes both structured data (when parsing succeeds) AND raw text (always, as fallback)
+2. `InteractiveUI.tsx`:
+   - When `type: "interactive_ui"` arrives via WebSocket:
+     - If structured data present: render as clickable cards/buttons
+     - If only raw text: render in a `
` block with generic navigation buttons (like ccbot's keyboard)
+     - AskUserQuestion → clickable option cards
+     - PermissionPrompt → "Allow" / "Deny" buttons with action description
+     - ExitPlanMode → "Proceed" / "Edit" buttons
+   - Clicking sends `send_key` back (Enter, Escape, Space, Tab, arrows)
+   - **Stale UI guard**: before sending a key, backend re-captures the pane and verifies the interactive UI is still showing. If not, discard the click and notify the user "This prompt has expired."
+3. Backend: detect interactive UIs via `terminal_parser.py`, parse via `ui_parser.py`, send structured data + raw text over WebSocket
+
+### Phase 6: Decision Grid
+1. `DecisionGrid.tsx`:
+   - Renders grid items as cards in a table/grid layout
+   - Each item shows topic, description, and radio buttons for options
+   - Recommended option highlighted
+   - "Submit All" button
+   - Overlay/modal that slides over the message stream
+2. Backend: poll `.ccweb/pending/` directory alongside JSONL monitoring (same poll loop, no extra loop)
+   - On new file: validate JSON schema, send to frontend, move to `.ccweb/completed/` on submit
+   - On invalid JSON: log warning, move to `.ccweb/failed/`, notify frontend with error
+   - Stale file cleanup: files older than 1 hour in `pending/` are moved to `failed/`
+   - `.ccweb/pending/` directory is auto-created by the backend on session creation
+3. Define the Claude Code skill contract (JSON schema for decision files)
+4. **Timing**: the skill MUST call AskUserQuestion after writing the file to block Claude (see Detection Mechanism section above)
+
+### Phase 7: Command Palette & Skill Picker
+1. **Command auto-complete in MessageInput**:
+   - Typing `/` in the text input triggers a dropdown above the input
+   - Built-in commands shown with descriptions: `/clear` (Clear history), `/compact` (Compact context), `/model` (Switch model), `/fast` (Toggle fast mode), `/plan` (Enter plan mode), `/cost` (Show usage), `/help`, `/memory`, `/config`, etc.
+   - Click to insert and send, or keep typing to filter
+2. **Repo skills discovery**:
+   - Backend endpoint: `GET /api/sessions/{window_id}/skills`
+   - Reads `{session_cwd}/.claude/commands/` directory for custom slash commands
+   - Parses command files to extract name + description (first line of the file is typically the description)
+   - Frontend shows these in the command dropdown, grouped under "Project Commands" separately from "Built-in Commands"
+   - Skills refresh when switching sessions (different repos have different skills)
+3. **Dropdown command selector**: A `▾` button next to the text input that opens the full command/skill list without needing to type `/`. Provides discoverability — browse all available commands and skills via click. Same grouped list as the auto-complete (Built-in Commands | Project Commands).
+4. **Toolbar quick-actions**: Persistent buttons above the message input for frequent actions:
+   - `/esc` (interrupt Claude) — prominent, always visible
+   - Command palette button (opens full command/skill list)
+   - Screenshot button (captures terminal as image, useful for sharing)
+
+### Phase 8: Message Filters
+1. **Filter bar** at the top of the message stream with toggleable chips:
+   - **All** — everything (default)
+   - **Chat** — only user messages + assistant text (hides tool_use, tool_result, thinking)
+   - **No thinking** — everything except thinking blocks
+   - **Tools** — only tool_use + tool_result (see what Claude did without the prose)
+2. Filter state persisted per session (localStorage) — each session can have its own filter
+3. Implementation: each message already has `content_type` from the JSONL parse, so filtering is a simple frontend predicate on the message list. No backend changes needed.
+
+### Phase 9: Documentation Wiki
+**Single source of truth**: Markdown files in `ccweb/docs/` serve both as repo-readable docs AND as the in-app wiki. No content duplication.
+
+**Doc structure** (`ccweb/docs/`):
+```
+docs/
+├── index.md                  # Home page, links to all sections
+├── getting-started/
+│   ├── installation.md       # Prerequisites, install steps, first run
+│   ├── setup.md              # Tailscale setup, .env config, ccweb install
+│   └── quickstart.md         # Create your first session, send a message, see response
+├── configuration/
+│   ├── env-variables.md      # Full .env reference table (every variable, default, description)
+│   ├── preferences.md        # Web UI settings page options
+│   └── claude-code-setup.md  # SessionStart hook, Claude Code configuration
+├── features/
+│   ├── sessions.md           # Creating, switching, killing sessions
+│   ├── message-stream.md     # Message types, markdown rendering, auto-scroll
+│   ├── interactive-ui.md     # AskUserQuestion, permissions, plan mode buttons
+│   ├── option-grid.md        # Decision grid: how it works, skill usage, JSON schema
+│   ├── custom-interactions.md # Checklist, status report, confirm dialog
+│   ├── commands.md           # /command auto-complete, dropdown, built-in vs project commands
+│   ├── message-filters.md    # Filtering by content type
+│   ├── subagents.md          # Subagent activity tracking and display
+│   ├── diff-viewer.md        # File diff rendering
+│   └── keyboard-shortcuts.md # All shortcuts reference
+├── architecture/
+│   ├── overview.md           # System architecture diagram
+│   ├── design-plan.md        # This plan document (original design decisions & rationale)
+│   ├── backend.md            # FastAPI, WebSocket protocol, session management
+│   ├── frontend.md           # React components, state management
+│   ├── ccweb-protocol.md     # Custom interaction type markers, JSON schemas
+│   └── reused-from-ccbot.md  # What was reused from ccbot and how
+├── troubleshooting/
+│   ├── common-issues.md      # FAQ: can't connect, sessions not appearing, etc.
+│   ├── websocket.md          # WebSocket connection issues, reconnection
+│   ├── tmux.md               # Tmux session issues, window ID resolution
+│   └── logs.md               # Where to find logs, debug mode
+└── changelog.md              # Version history
+```
+
+**Every doc file has:**
+- YAML frontmatter: `title`, `description`, `order` (for sidebar sorting)
+- Table of contents (auto-generated from headings)
+- Internal links using relative paths: `[Option Grid](../features/option-grid.md)`
+- Works when browsing on GitHub/filesystem AND in the web UI
+
+**In-app wiki (`/wiki` route in frontend):**
+- `WikiPage.tsx` — renders a single doc page with react-markdown
+- `WikiSidebar.tsx` — navigation tree built from the docs/ directory structure
+- Backend: `GET /api/docs` returns the doc tree (filenames + frontmatter)
+- Backend: `GET /api/docs/{path}` returns raw markdown content for a specific file
+- Internal links rewritten to `#/wiki/...` routes in the web UI
+- Search: full-text search across all doc files (backend endpoint)
+- Breadcrumb navigation at top of each page
+
+### Phase 10: Responsive Design (Desktop + Tablet)
+Target: Chrome for Windows (desktop) + Chrome for Android on Samsung Z Fold 7 (tablet mode).
+
+1. **Layout breakpoints**:
+   - Desktop (>1024px): sidebar visible + main content area side by side
+   - Tablet (768-1024px): sidebar as hamburger drawer, full-width content
+   - The Z Fold 7 in tablet mode is ~7.6" at ~904px width → tablet layout
+2. **Touch-friendly**:
+   - All interactive elements minimum 44x44px touch targets
+   - Swipe gestures: swipe right to open sidebar, swipe left to close
+   - Decision grid options as large tap targets (cards, not tiny radio buttons)
+   - Submit button prominently sized
+3. **Input handling**:
+   - On tablet: virtual keyboard push-up handling (input stays visible above keyboard)
+   - Auto-resize text area as you type
+4. **CSS approach**: Tailwind CSS with responsive utilities (`md:`, `lg:` prefixes)
+   - Flexbox layout that reflows naturally
+   - No fixed pixel widths on content areas
+
+### Phase 11: Quality-of-Life Features
+
+1. **Browser notifications** (`useNotifications.ts`):
+   - Request notification permission on first visit
+   - Push notification when: Claude finishes a task (stop_reason set), interactive UI appears (AskUserQuestion, permission), subagent completes
+   - Only fires when the tab is not focused or screen is off
+   - Notification click brings you to the correct session
+   - On Android/tablet: works with Chrome's built-in notification system
+
+2. **Context & cost indicator** (`ContextBar.tsx`):
+   - Persistent badge in the status bar: "Context: 34% | $2.15"
+   - Backend periodically captures `/usage` output via terminal_parser's `parse_usage_output()`
+   - Or: parse the status line chrome (bottom bar of Claude Code pane shows model + context %)
+   - Color changes: green (<50%), yellow (50-80%), red (>80%)
+   - Click to expand full usage details (token counts, rate limits)
+
+3. **Session persistence on page reload**:
+   - On WebSocket connect, backend sends full session list + which session was last active
+   - Client stores last active session in localStorage
+   - On reconnect: auto-binds to last session, requests message history via `get_recent_messages()`
+   - Messages load from JSONL (the full history is always there), so no data loss on browser close/refresh
+
+4. **Copy buttons on code blocks** (`CodeBlock.tsx`):
+   - One-click copy icon (📋) on every fenced code block, inline code, diff output, and command output
+   - "Copied!" toast feedback
+   - For diffs: option to copy just the new content (without diff markers)
+   - Uses `navigator.clipboard.writeText()`
+
+5. **Connection status indicator**:
+   - Small badge in the top bar: 🟢 Connected | 🟡 Reconnecting | 🔴 Disconnected
+   - On disconnect: auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
+   - On reconnect: replays any missed messages (backend tracks last-delivered offset per client)
+   - Manual "Reconnect" button when auto-reconnect fails
+
+6. **Session rename**:
+   - Double-click session name in sidebar → inline edit field
+   - Or right-click → "Rename" context menu
+   - Backend calls `tmux_manager.rename_window()` + updates display name in session state
+   - Updates reflected immediately across all connected clients
+
+7. **Export conversation** (`ExportButton.tsx`):
+   - Button in session header: "Export" → dropdown: Markdown / JSON / Plain Text
+   - **Markdown**: formatted conversation with headers, code blocks, tool summaries (ready for sharing)
+   - **JSON**: raw JSONL entries (for programmatic use)
+   - **Plain text**: stripped of all formatting
+   - Filters applied: export respects current message filter (e.g., "Chat only" exports only user+assistant text)
+   - Downloads as a file: `{session-name}-{date}.md`
+
+### Phase 12: Polish & UX
+1. Auto-scroll behavior (pause on scroll-up, resume on new messages)
+2. Expandable blocks for thinking/tool results (click to expand/collapse)
+3. Image rendering for tool_result images (base64 → inline img)
+4. WebSocket reconnection (auto-reconnect + replay missed messages)
+5. Keyboard shortcuts: Escape to interrupt, Ctrl+K for command palette, Ctrl+N for new session
+6. Dark/light theme toggle (default dark, matching terminal aesthetic)
+7. Notification badge on sessions with unread messages (when viewing a different session)
+
+## Technical Issues to Address (from review)
+
+1. **SessionMonitor single callback**: Wrap in a fan-out broadcaster. One callback registered, it iterates all connected WebSocket clients and sends. Single-user means this is simple.
+
+2. **Byte offset + WebSocket delivery**: Since single-user, offsets persist after `ws.send()` (fire-and-forget). On reconnect, client sends its last-received message timestamp; backend replays from that point via `get_recent_messages()` with byte offset.
+
+3. **WebSocket keepalive**: Add ping/pong frames (FastAPI/Starlette supports this natively with `websocket.send_json` + periodic pings). 30s interval.
+
+4. **Error message type**: Add `{"type": "error", "code": "...", "message": "..."}` to the WebSocket protocol for backend failures (tmux down, session_map corrupt, etc.).
+
+5. **`session_map.json` read race**: Use `fcntl.flock` for reads too (shared lock), matching the hook's exclusive lock on writes. Or: read with retry on `JSONDecodeError`.
+
+6. **`config.py` import chain**: The adapted `config.py` for ccweb does NOT import Telegram config. All "copy verbatim" modules import from `ccweb.backend.core.config` (the new one), not the old ccbot config. This breaks the Telegram dependency chain entirely.
+
+7. **WebSocket protocol additions**:
+   - `{"type": "ping"}` / `{"type": "pong"}` — keepalive
+   - `{"type": "error", "code": "...", "message": "..."}` — backend errors
+   - `{"type": "replay_request", "since_timestamp": "..."}` — client reconnection catch-up
+   - `{"type": "replay", "messages": [...]}` — server replay response
+
+## V2 Roadmap (deferred features)
+
+Saved as `ccweb/docs/architecture/v2-roadmap.md` alongside the design plan.
+
+### Rich Tool Output (from Phase 7.5, deferred)
+- **File diff viewer** (`DiffViewer.tsx`): Edit tool diffs rendered with green/red line highlighting, collapsible per file
+- **Progress tracker** (`ProgressTracker.tsx`): TodoWrite rendered as persistent checkbox panel, pinned above message stream
+- **Search results cards** (`SearchResults.tsx`): WebSearch as cards with title/snippet/link, Grep as highlighted matches
+- **Destructive action warning**: Permission prompts for `rm`, `reset --hard` etc. rendered with red highlight border
+
+### Subagent Activity Tracking (from Phase 7.6, deferred)
+- Collapsible task cards in message stream (header: "Agent: {description}" with running/complete indicator)
+- Subagent status badge ("2 agents running")
+- Future: monitor subagent JSONL files directly for real-time streaming
+
+### Saved Prompts Library (from Phase 8, deferred)
+- Prompt Library UI with title, category, preview, fuzzy search
+- Variable placeholders: `{{filename}}`, `{{description}}` → form fill on insert
+- Global prompts (`~/.ccweb/prompts/`) + per-project prompts (`{project}/.ccweb/prompts/`)
+- CRUD API endpoints
+
+### Creative UX Explorations
+Ideas from the creative review to explore for v2:
+- **Message threading**: Nest question/answer exchanges visually (Slack-style threads)
+- **Optimistic input**: Show user's message immediately with pending indicator before tmux round-trip
+- **Session timeline scrubber**: Horizontal minimap of session phases (thinking/tool use/conversation/idle), click to jump
+- **Live file preview pane**: Split view showing file's current state after Edit, not just the diff
+- **Pinned messages**: Pin key decisions/outputs to the top of a session
+- **Session heatmap**: Which sessions were active when, token spend per session
+- **Quick reactions on tool results**: Thumbs-up/flag individual outputs as feedback
+- **Minimap**: VS Code-style vertical scroll overview of message density and type
+- **Keyboard-first navigation**: Linear-style shortcuts (G+S for sessions, G+P for prompts)
+- **Stackable message filters**: Combine filters (e.g., "chat + tools but not thinking")
+
+### Decision Grid v2 Improvements
+- **Keyboard navigation**: Arrow keys between cells, Enter to select, Tab to notes column
+- **Comparison mode**: Select two options, see side-by-side with differences highlighted
+- **Fuzzy search in saved prompts**: Raycast/Alfred-style instant matching
+- **Collapsible file-change groups**: GitHub-style when diffs are numerous
+
+## Key Files to Create
+
+| File | Purpose |
+|------|---------|
+| `ccweb/pyproject.toml` | Python package: fastapi, uvicorn, libtmux, aiofiles, dotenv |
+| `ccweb/backend/core/__init__.py` | Core module init |
+| `ccweb/backend/core/tmux_manager.py` | Copy from ccbot |
+| `ccweb/backend/core/terminal_parser.py` | Copy from ccbot |
+| `ccweb/backend/core/transcript_parser.py` | Adapted from ccbot |
+| `ccweb/backend/core/session_monitor.py` | Copy from ccbot |
+| `ccweb/backend/core/monitor_state.py` | Copy from ccbot |
+| `ccweb/backend/core/hook.py` | Copy from ccbot |
+| `ccweb/backend/core/utils.py` | Copy from ccbot |
+| `ccweb/backend/config.py` | Web-specific configuration |
+| `ccweb/backend/session.py` | Simplified session management |
+| `ccweb/backend/server.py` | FastAPI + WebSocket server |
+| `ccweb/backend/ws_protocol.py` | Message type definitions |
+| `ccweb/backend/main.py` | CLI entry point |
+| `ccweb/frontend/package.json` | React app dependencies |
+| `ccweb/frontend/src/App.tsx` | Main layout |
+| `ccweb/frontend/src/protocol.ts` | WebSocket message types |
+| `ccweb/frontend/src/hooks/useWebSocket.ts` | WebSocket hook |
+| `ccweb/frontend/src/components/MessageStream.tsx` | Message display |
+| `ccweb/frontend/src/components/MessageInput.tsx` | Text input |
+| `ccweb/frontend/src/components/SessionSidebar.tsx` | Session list |
+| `ccweb/frontend/src/components/InteractiveUI.tsx` | Interactive prompts |
+| `ccweb/frontend/src/components/DecisionGrid.tsx` | Option grid |
+| `ccweb/frontend/src/components/StatusBar.tsx` | Status display |
+| `ccweb/frontend/src/components/ExpandableBlock.tsx` | Collapsible content |
+
+## Startup Health Checks & Error UX
+
+On startup, `ccweb` runs validation before accepting connections:
+
+1. **tmux running?** — Check `tmux list-sessions`. If not: print clear error "tmux is not running. Start tmux first: `tmux new -s ccbot`"
+2. **Hook installed?** — Check `~/.claude/settings.json` for SessionStart hook pointing to `ccweb hook`. If not: print warning "SessionStart hook not installed. Run `ccweb install` first. Sessions will not be monitored."
+3. **State directory exists?** — Create `~/.ccweb/` if missing.
+4. **Config valid?** — Validate .env loaded, required vars present.
+
+On WebSocket connect, send a `{"type": "health", "status": {...}}` message with:
+- `tmux_running: bool`
+- `hook_installed: bool`
+- `sessions_found: int`
+- `warnings: string[]`
+
+Frontend renders a diagnostic banner for any issues: "Hook not installed — run `ccweb install`" with a one-click fix button (calls backend endpoint that installs the hook).
+
+**Error states in the UI**:
+- tmux not running → red banner: "tmux is not running"
+- WebSocket disconnected → yellow banner with reconnect button
+- Session killed externally → session shows "(ended)" in sidebar with explanation
+- Hook not installed → orange banner with install button
+- No sessions → helpful empty state: "No sessions yet. Click + New Session to start."
+
+## Backend Server Design (`server.py`)
+
+```python
+# FastAPI app serves:
+# - GET / → React static files (production) or proxy to Vite dev server
+# - WebSocket /ws → bidirectional real-time communication
+# - GET /api/sessions → list active sessions
+# - POST /api/sessions → create new session
+# - DELETE /api/sessions/{window_id} → kill session
+
+# On startup:
+# 1. Initialize TmuxManager, SessionManager, SessionMonitor
+# 2. Start SessionMonitor polling loop
+# 3. Start status polling loop (1s interval, like ccbot)
+# 4. Set message callback to broadcast to connected WebSocket clients
+
+# WebSocket handler:
+# - On connect: send current session list + bind to active window
+# - On message: dispatch by type (send_text, send_key, submit_decisions, etc.)
+# - On disconnect: clean up client binding
+
+# Message callback (from SessionMonitor):
+# - For each NewMessage, find connected clients bound to that window
+# - Serialize and send via WebSocket
+# - For AskUserQuestion/ExitPlanMode tool_use: capture terminal, 
+#   parse interactive UI, send structured data instead of raw text
+```
+
+## Hosting & Remote Access
+
+The FastAPI server serves both the API (WebSocket + REST) and the built React static files on a **single port** (default 8765). No separate frontend hosting needed.
+
+**With Tailscale (user's current setup):**
+- Server binds to `0.0.0.0:8765`
+- Access from any device on the tailnet: `http://:8765`
+- Tailscale ACLs control access (no separate auth needed if tailnet is trusted)
+- Optional HTTPS: `tailscale cert` + uvicorn SSL config, or `tailscale serve --bg 8765` for automatic HTTPS at `https://.tail-net.ts.net`
+
+**Server config** (`.env` file in `~/.ccweb/` or env vars — set once, requires restart):
+```bash
+CCWEB_HOST=0.0.0.0          # Bind address
+CCWEB_PORT=8765              # Port
+CCWEB_AUTH_TOKEN=            # Optional bearer token (empty = no auth, rely on Tailscale)
+TMUX_SESSION_NAME=ccbot      # Tmux session name
+CLAUDE_COMMAND=claude         # Command to start Claude Code
+CCWEB_BROWSE_ROOT=           # Starting dir for session browser (empty = home)
+MONITOR_POLL_INTERVAL=2.0    # Seconds between JSONL polls
+CCWEB_SHOW_HIDDEN_DIRS=false # Show dot-dirs in browser
+# Memory monitoring
+CCWEB_MEMORY_MONITOR=true
+CCWEB_MEM_AVAIL_WARN_MB=1024
+CCWEB_MEM_AVAIL_INTERRUPT_MB=512
+CCWEB_MEM_AVAIL_KILL_MB=256
+```
+
+**User preferences** (settings page in the web UI — changeable live, persisted to `~/.ccweb/preferences.json`):
+- Theme: dark / light
+- Default message filter: All / Chat / No thinking / Tools
+- Show hidden directories in browser
+- Auto-scroll behavior (pause on scroll-up, resume on new messages)
+- Submit shortcut: Ctrl+Enter / Cmd+Enter toggle
+
+The settings page is accessible via a gear icon in the sidebar. Changes take effect immediately without restart.
+
+**Production serving:**
+- `uvicorn ccweb.backend.main:app --host 0.0.0.0 --port 8765`
+- React app built with `npm run build` → static files served by FastAPI's `StaticFiles` mount
+- In dev: Vite dev server on :5173 proxies API calls to FastAPI on :8765
+- **IMPORTANT**: Vite proxy must explicitly enable WebSocket: `server.proxy["/ws"] = { target: "ws://localhost:8765", ws: true }` in `vite.config.ts`. Without `ws: true`, WebSocket upgrade handshake fails silently.
+
+## Per-Project Setup (What a new repo needs)
+
+**Short answer**: Run `ccweb install` once, and new projects work automatically.
+
+**What `ccweb install` sets up globally (one time):**
+- `~/.claude/settings.json` → SessionStart hook (writes session_map.json when Claude starts)
+- `~/.claude/commands/option-grid.md` → option grid slash command
+- `~/.claude/commands/checklist.md`, `status-report.md`, `confirm.md` → other interaction commands
+
+**What a new project gets for free (zero setup):**
+- Session creation via ccweb's directory browser
+- Real-time message streaming
+- All interactive UI handling (AskUserQuestion, permissions, plan mode)
+- All built-in /commands and the global ccweb skills
+- File upload, message filters, subagent tracking, diff viewer, etc.
+
+**Optional per-project enhancements** (add to the project's `CLAUDE.md` if you want Claude to proactively use ccweb features):
+```markdown
+## CCWeb Integration
+- When presenting multiple options/decisions to the user, use the /option-grid command
+- When reporting build/test status, use the /status-report command
+- For critical destructive actions, use the /confirm command before proceeding
+```
+
+**Optional per-project prompts** (for the Saved Prompts library):
+- Create `{project}/.ccweb/prompts/deploy.md`, `review.md`, etc.
+- These show up in the prompt library only when that project's session is active
+
+**Per-project CCWeb instructions** (avoids mutating CLAUDE.md directly):
+Instead of writing into CLAUDE.md (which creates git noise, merge conflicts, and meaningless diffs), ccweb creates a separate `.ccweb/instructions.md` file that CLAUDE.md can reference:
+
+1. **On session creation**: If the project has no `.ccweb/instructions.md`, a subtle banner appears: "Enable CCWeb features for this project?" → click to create.
+2. **"Setup CCWeb" button**: Available in the session sidebar (per-session gear icon). One click creates/updates `.ccweb/instructions.md`.
+3. **The file** contains ccweb-specific instructions for Claude:
+```markdown
+## CCWeb Integration
+- When presenting multiple options/decisions to the user, use /option-grid
+- When reporting build/test/deploy status, use /status-report
+- For interactive checklists, use /checklist
+- For critical destructive actions, use /confirm before proceeding
+```
+4. **CLAUDE.md reference** (optional, user adds manually if they want): Add one line to CLAUDE.md: `See @.ccweb/instructions.md for CCWeb integration.`
+5. `.ccweb/` can be `.gitignore`d if the user doesn't want it tracked — no git noise.
+6. **Backend**: `POST /api/sessions/{window_id}/setup-ccweb` → creates `.ccweb/instructions.md` in the session's working directory.
+
+**Summary**: A bare repo with no ccweb-specific files works perfectly. The CLAUDE.md management is an optional convenience — one click to enable, auto-updated as ccweb evolves, and clearly delimited so it doesn't interfere with your own CLAUDE.md content.
+
+## Portability
+
+The `ccweb/` folder is **completely self-contained** — no imports from the parent ccbot-workshop repo. All reused modules are copied into `ccweb/backend/core/`. To make it a standalone repo:
+
+1. Copy `ccweb/` to a new location
+2. `git init` → it's a new repo
+3. Has its own `pyproject.toml`, `package.json`, README, docs, etc.
+4. No changes needed — everything works out of the box
+
+The only shared resource is the tmux session itself. State is fully separate:
+- ccbot uses `~/.ccbot/` (state.json, session_map.json, monitor_state.json)
+- ccweb uses `~/.ccweb/` (its own state.json, session_map.json, monitor_state.json)
+- Both can monitor the same tmux windows without conflict (read-only JSONL access)
+- The SessionStart hook must be `ccweb hook` (writes to `~/.ccweb/`), not `ccbot hook`
+- If running both simultaneously, install BOTH hooks in `~/.claude/settings.json`
+
+## Key Reusable Methods from session.py
+
+The adapted `session.py` will keep these critical methods (changing only the binding model):
+- `resolve_stale_ids()` — re-resolve window IDs after tmux restart
+- `load_session_map()` — read hook-generated session_map.json
+- `get_window_state()` / `clear_window_session()` — window state management
+- `send_to_window()` — send text + auto-resume Claude if exited
+- `get_recent_messages()` — retrieve history with byte-range support
+- `resolve_session_for_window()` — window_id → ClaudeSession resolution
+- `wait_for_session_map_entry()` — poll for hook to fire after window creation
+- `find_users_for_session()` → renamed `find_clients_for_session()` — route incoming messages to connected WebSocket clients
+
+**Binding model change**: Replace `thread_bindings: dict[int, dict[int, str]]` (user_id → {thread_id → window_id}) with `client_bindings: dict[str, str]` (client_id → window_id). Each WebSocket connection has a unique client_id and can be bound to one window at a time.
+
+## Verification Plan
+
+1. **Backend standalone**: Start server, connect WebSocket client (wscat), verify session list, create session, send text, receive messages
+2. **Frontend dev**: `npm run dev` in frontend/, verify React app loads, WebSocket connects
+3. **End-to-end**: Create session via UI, send a message, see Claude's response stream in, interact with AskUserQuestion via clickable options
+4. **Decision grid**: Manually create a test `.ccweb/decisions/test.json`, verify it renders in the frontend, submit selections, verify text arrives in Claude's tmux pane
+5. **Linting**: `ruff check` + `ruff format` + `pyright` on all Python files in `ccweb/backend/`
diff --git a/ccweb/docs/architecture/v2-roadmap.md b/ccweb/docs/architecture/v2-roadmap.md
new file mode 100644
index 00000000..7a79ac89
--- /dev/null
+++ b/ccweb/docs/architecture/v2-roadmap.md
@@ -0,0 +1,78 @@
+---
+title: V2 Roadmap
+description: Deferred features and future enhancements for CCWeb
+order: 2
+---
+
+# CCWeb V2 Roadmap
+
+Features deferred from v1, organized by category.
+
+## Table of Contents
+
+- [Rich Tool Output](#rich-tool-output)
+- [Subagent Activity Tracking](#subagent-activity-tracking)
+- [Saved Prompts Library](#saved-prompts-library)
+- [Creative UX Explorations](#creative-ux-explorations)
+- [Decision Grid v2 Improvements](#decision-grid-v2-improvements)
+- [Performance: Streaming via inotify](#performance-streaming-via-inotify)
+
+---
+
+## Rich Tool Output
+
+Replace `
` blocks with interactive, tool-specific rendering:
+
+- **File diff viewer** (`DiffViewer.tsx`): Edit tool diffs rendered with green/red line highlighting, collapsible per file. One of the most frequent tool outputs — huge UX improvement.
+- **Progress tracker** (`ProgressTracker.tsx`): TodoWrite rendered as persistent checkbox panel, pinned above message stream. Updates in real-time as Claude marks items complete.
+- **Search results cards** (`SearchResults.tsx`): WebSearch as cards with title/snippet/link. Grep as file paths with highlighted match lines. Glob as file tree-style list.
+- **Destructive action warning**: Permission prompts for `rm`, `reset --hard`, `drop`, `delete` rendered with red highlight border and explicit warning text.
+
+## Subagent Activity Tracking
+
+Better visibility into Claude's Agent/Task tool usage:
+
+- Collapsible task cards in message stream (header: "Agent: {description}" with running/complete indicator)
+- Visually nested/indented from the parent conversation
+- Subagent status badge ("2 agents running") that updates in real-time
+- Future: monitor subagent JSONL files directly for real-time streaming of subagent activity (not just final results)
+
+## Saved Prompts Library
+
+System for creating, storing, and reusing frequently-used prompts:
+
+- **Prompt Library UI** with title, category, preview, fuzzy search (Raycast/Alfred-style)
+- **Variable placeholders**: `{{filename}}`, `{{description}}` — form pops up on insert to fill them in
+- **Global prompts** in `~/.ccweb/prompts/` + **project prompts** in `{project}/.ccweb/prompts/`
+- CRUD API endpoints: `GET/POST/PUT/DELETE /api/prompts`
+
+## Creative UX Explorations
+
+Ideas from design review to explore:
+
+- **Message threading**: Nest question/answer exchanges visually (Slack-style threads)
+- **Optimistic input**: Show user's message immediately with pending indicator before tmux round-trip
+- **Session timeline scrubber**: Horizontal minimap of session phases (thinking/tool use/conversation/idle), click to jump
+- **Live file preview pane**: Split view showing file's current state after Edit, not just the diff
+- **Pinned messages**: Pin key decisions/outputs to the top of a session
+- **Session heatmap**: Which sessions were active when, token spend per session
+- **Quick reactions on tool results**: Thumbs-up/flag individual outputs as feedback
+- **Minimap**: VS Code-style vertical scroll overview of message density and type
+- **Keyboard-first navigation**: Linear-style shortcuts (G+S for sessions, G+P for prompts)
+- **Stackable message filters**: Combine filters (e.g., "chat + tools but not thinking")
+
+## Decision Grid v2 Improvements
+
+- **Keyboard navigation**: Arrow keys between cells, Enter to select, Tab to notes column
+- **Comparison mode**: Select two options, see side-by-side with differences highlighted
+- **"Explain this option" button**: Per-row button that asks Claude for elaboration without losing context
+- **Collapsible file-change groups**: GitHub-style when diffs are numerous
+
+## Performance: Streaming via inotify
+
+Replace 2-second JSONL polling with file-system event monitoring:
+
+- Use `watchdog` or `inotify` to detect JSONL file changes instantly
+- Reduces response latency from 2s+ to near-instant
+- Status bar already provides 1s feedback, but streaming content would be dramatically better
+- Requires careful handling of partial writes (same partial-line logic as current monitor)
diff --git a/ccweb/pyproject.toml b/ccweb/pyproject.toml
new file mode 100644
index 00000000..14a19bcb
--- /dev/null
+++ b/ccweb/pyproject.toml
@@ -0,0 +1,43 @@
+[project]
+name = "ccweb"
+version = "0.1.0"
+description = "React web gateway to Claude Code sessions via tmux"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.115.0",
+    "uvicorn[standard]>=0.34.0",
+    "websockets>=14.0",
+    "libtmux>=0.37.0",
+    "aiofiles>=24.0.0",
+    "python-dotenv>=1.0.0",
+]
+
+[project.scripts]
+ccweb = "ccweb.backend.main:main"
+
+[project.optional-dependencies]
+dev = [
+    "pyright>=1.1.0",
+    "pytest>=8.0",
+    "pytest-asyncio>=0.24.0",
+    "ruff>=0.8.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["ccweb/backend"]
+
+[tool.ruff]
+target-version = "py312"
+
+[tool.pyright]
+pythonVersion = "3.12"
+typeCheckingMode = "basic"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"

From 1aad315f78ee91db3fab4db04f2bfd7dd5fc1ea0 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 11 Apr 2026 00:07:29 +0000
Subject: [PATCH 31/57] Fix bugs found by adversarial code review

- Fix stale UI guard blocking Escape-to-interrupt: Escape key is now
  always allowed through (it's the interrupt key), guard only applies
  to interactive UI navigation keys
- Add lookup_window_state() that doesn't auto-create phantom entries,
  use it in _handle_new_message to prevent state pollution and disk
  I/O churn from create-delete cycles
- Remove dead code find_clients_for_session (misleading name, never called)
- Implement decision grid file polling in status_poll_loop: watches
  .ccweb/pending/*.json, validates JSON, sends to frontend, moves to
  completed/ or failed/
- Add submit_decisions WebSocket handler: formats user selections as
  text and sends to Claude via tmux, moves grid files to completed/

https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw
---
 ccweb/backend/server.py  | 122 ++++++++++++++++++++++++++++++++++-----
 ccweb/backend/session.py |  16 ++---
 2 files changed, 112 insertions(+), 26 deletions(-)

diff --git a/ccweb/backend/server.py b/ccweb/backend/server.py
index e7c6759b..2cbe2512 100644
--- a/ccweb/backend/server.py
+++ b/ccweb/backend/server.py
@@ -43,7 +43,9 @@
     CLIENT_PING,
     CLIENT_SEND_KEY,
     CLIENT_SEND_TEXT,
+    CLIENT_SUBMIT_DECISIONS,
     CLIENT_SWITCH_SESSION,
+    WsDecisionGrid,
     WsError,
     WsHealth,
     WsInteractiveUI,
@@ -166,8 +168,9 @@ async def _handle_new_message(msg: NewMessage) -> None:
             continue
 
         # Check if this window's session matches the message's session
-        state = session_manager.get_window_state(window_id)
-        if state.session_id != msg.session_id:
+        # Use lookup (no auto-create) to avoid phantom state entries
+        state = session_manager.lookup_window_state(window_id)
+        if not state or state.session_id != msg.session_id:
             continue
 
         ws_msg = WsMessage(
@@ -186,9 +189,51 @@ async def _handle_new_message(msg: NewMessage) -> None:
 
 # ── Status polling ───────────────────────────────────────────────────────
 
+# Track grid files already sent to frontend (avoid re-sending)
+_sent_grid_files: set[str] = set()
+
+
+async def _check_decision_grids(window_id: str, ws: WebSocket) -> None:
+    """Check for new decision grid files in .ccweb/pending/ for a window."""
+    from .session import session_manager
+
+    state = session_manager.lookup_window_state(window_id)
+    if not state or not state.cwd:
+        return
+
+    pending_dir = Path(state.cwd) / ".ccweb" / "pending"
+    if not pending_dir.exists():
+        return
+
+    for grid_file in pending_dir.glob("*.json"):
+        file_key = str(grid_file)
+        if file_key in _sent_grid_files:
+            continue
+
+        try:
+            grid_data = json.loads(grid_file.read_text())
+        except (json.JSONDecodeError, OSError) as e:
+            logger.warning("Invalid grid file %s: %s", grid_file, e)
+            # Move to failed/
+            failed_dir = pending_dir.parent / "failed"
+            failed_dir.mkdir(exist_ok=True)
+            grid_file.rename(failed_dir / grid_file.name)
+            continue
+
+        _sent_grid_files.add(file_key)
+        grid_msg = WsDecisionGrid(
+            window_id=window_id,
+            grid=grid_data,
+        )
+        try:
+            await ws.send_json(grid_msg.to_dict())
+            logger.info("Sent decision grid to client: %s", grid_file.name)
+        except Exception:
+            _sent_grid_files.discard(file_key)
+
 
 async def _status_poll_loop() -> None:
-    """Poll terminal status for all active windows at 1-second intervals."""
+    """Poll terminal status and decision grids at 1-second intervals."""
     while True:
         try:
             for client_id, window_id in list(_client_bindings.items()):
@@ -200,6 +245,9 @@ async def _status_poll_loop() -> None:
                 if not w:
                     continue
 
+                # Check for decision grid files
+                await _check_decision_grids(window_id, ws)
+
                 pane_text = await tmux_manager.capture_pane(w.window_id)
                 if not pane_text:
                     continue
@@ -286,18 +334,20 @@ async def _handle_ws_message(
     if msg_type == CLIENT_SEND_KEY:
         key = data.get("key", "")
         if key and window_id:
-            # Stale UI guard: verify interactive UI is still showing
-            w = await tmux_manager.find_window_by_id(window_id)
-            if w:
-                pane_text = await tmux_manager.capture_pane(w.window_id)
-                if pane_text and not is_interactive_ui(pane_text):
-                    await ws.send_json(
-                        WsError(
-                            code="stale_ui",
-                            message="This prompt has expired.",
-                        ).to_dict()
-                    )
-                    return
+            # Escape is always allowed — it's the interrupt key
+            # Other keys get a stale UI guard to prevent blind key injection
+            if key != "Escape":
+                w = await tmux_manager.find_window_by_id(window_id)
+                if w:
+                    pane_text = await tmux_manager.capture_pane(w.window_id)
+                    if pane_text and not is_interactive_ui(pane_text):
+                        await ws.send_json(
+                            WsError(
+                                code="stale_ui",
+                                message="This prompt has expired.",
+                            ).to_dict()
+                        )
+                        return
 
             # Map key names to tmux send_keys parameters
             key_map: dict[str, tuple[str, bool, bool]] = {
@@ -359,6 +409,48 @@ async def _handle_ws_message(
             )
         return
 
+    if msg_type == CLIENT_SUBMIT_DECISIONS:
+        selections = data.get("selections", [])
+        grid_title = data.get("title", "Decisions")
+        if selections and window_id:
+            # Format selections as text for Claude
+            lines = [f'Decisions for "{grid_title}":']
+            for sel in selections:
+                topic = sel.get("topic", "")
+                choice = sel.get("choice")
+                notes = sel.get("notes", "")
+                if choice:
+                    lines.append(f"- {topic}: {choice}")
+                    if notes:
+                        lines.append(f'  Note: "{notes}"')
+                elif notes:
+                    lines.append(f'- {topic}: [Custom] "{notes}"')
+                else:
+                    lines.append(f"- {topic}: (no selection)")
+
+            formatted = "\n".join(lines)
+
+            from .session import session_manager
+
+            success, message = await session_manager.send_to_window(
+                window_id, formatted
+            )
+            if not success:
+                await ws.send_json(
+                    WsError(code="send_failed", message=message).to_dict()
+                )
+            else:
+                # Move grid file to completed/
+                state = session_manager.lookup_window_state(window_id)
+                if state and state.cwd:
+                    pending_dir = Path(state.cwd) / ".ccweb" / "pending"
+                    completed_dir = Path(state.cwd) / ".ccweb" / "completed"
+                    completed_dir.mkdir(parents=True, exist_ok=True)
+                    for f in pending_dir.glob("*.json"):
+                        f.rename(completed_dir / f.name)
+                        _sent_grid_files.discard(str(f))
+        return
+
     logger.warning("Unknown WebSocket message type: %s", msg_type)
 
 
diff --git a/ccweb/backend/session.py b/ccweb/backend/session.py
index 64543307..18a48636 100644
--- a/ccweb/backend/session.py
+++ b/ccweb/backend/session.py
@@ -223,10 +223,15 @@ def get_display_name(self, window_id: str) -> str:
     # --- Window state management ---
 
     def get_window_state(self, window_id: str) -> WindowState:
+        """Get or create window state (use for write paths like load_session_map)."""
         if window_id not in self.window_states:
             self.window_states[window_id] = WindowState()
         return self.window_states[window_id]
 
+    def lookup_window_state(self, window_id: str) -> WindowState | None:
+        """Look up window state without creating it (use for read-only checks)."""
+        return self.window_states.get(window_id)
+
     # --- Window → Session resolution ---
 
     def _build_session_file_path(self, session_id: str, cwd: str) -> Path | None:
@@ -368,17 +373,6 @@ async def get_recent_messages(
         ]
         return all_messages, len(all_messages)
 
-    async def find_clients_for_session(
-        self,
-        session_id: str,
-    ) -> list[str]:
-        """Find all window_ids whose session matches the given session_id."""
-        result: list[str] = []
-        for window_id, state in self.window_states.items():
-            if state.session_id == session_id:
-                result.append(window_id)
-        return result
-
 
 # Singleton
 session_manager = SessionManager()

From ea142013f7161bcaea698b2bc7bcd0dcddaea437 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 11 Apr 2026 00:11:11 +0000
Subject: [PATCH 32/57] Fix packaging: restructure for correct wheel build

Found by adversarial import chain review:

- Move backend/ under ccweb/ccweb/ so hatch builds a proper wheel
  with the ccweb.backend module hierarchy matching the entry point
- Add ccweb/__init__.py as top-level package marker
- Fix pyproject.toml: packages = ["ccweb"], inline readme (no missing file)
- Fix static files path in server.py (extra parent level after restructure)
- Fix CLI dispatch: `ccweb garbage` now shows error instead of silently
  starting the server

https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw
---
 ccweb/ccweb/__init__.py                             | 1 +
 ccweb/{ => ccweb}/backend/__init__.py               | 0
 ccweb/{ => ccweb}/backend/config.py                 | 0
 ccweb/{ => ccweb}/backend/core/__init__.py          | 0
 ccweb/{ => ccweb}/backend/core/hook.py              | 0
 ccweb/{ => ccweb}/backend/core/monitor_state.py     | 0
 ccweb/{ => ccweb}/backend/core/session_monitor.py   | 0
 ccweb/{ => ccweb}/backend/core/terminal_parser.py   | 0
 ccweb/{ => ccweb}/backend/core/tmux_manager.py      | 0
 ccweb/{ => ccweb}/backend/core/transcript_parser.py | 0
 ccweb/{ => ccweb}/backend/core/utils.py             | 0
 ccweb/{ => ccweb}/backend/main.py                   | 2 +-
 ccweb/{ => ccweb}/backend/server.py                 | 3 ++-
 ccweb/{ => ccweb}/backend/session.py                | 0
 ccweb/{ => ccweb}/backend/ui_parser.py              | 0
 ccweb/{ => ccweb}/backend/ws_protocol.py            | 0
 ccweb/pyproject.toml                                | 4 ++--
 17 files changed, 6 insertions(+), 4 deletions(-)
 create mode 100644 ccweb/ccweb/__init__.py
 rename ccweb/{ => ccweb}/backend/__init__.py (100%)
 rename ccweb/{ => ccweb}/backend/config.py (100%)
 rename ccweb/{ => ccweb}/backend/core/__init__.py (100%)
 rename ccweb/{ => ccweb}/backend/core/hook.py (100%)
 rename ccweb/{ => ccweb}/backend/core/monitor_state.py (100%)
 rename ccweb/{ => ccweb}/backend/core/session_monitor.py (100%)
 rename ccweb/{ => ccweb}/backend/core/terminal_parser.py (100%)
 rename ccweb/{ => ccweb}/backend/core/tmux_manager.py (100%)
 rename ccweb/{ => ccweb}/backend/core/transcript_parser.py (100%)
 rename ccweb/{ => ccweb}/backend/core/utils.py (100%)
 rename ccweb/{ => ccweb}/backend/main.py (99%)
 rename ccweb/{ => ccweb}/backend/server.py (99%)
 rename ccweb/{ => ccweb}/backend/session.py (100%)
 rename ccweb/{ => ccweb}/backend/ui_parser.py (100%)
 rename ccweb/{ => ccweb}/backend/ws_protocol.py (100%)

diff --git a/ccweb/ccweb/__init__.py b/ccweb/ccweb/__init__.py
new file mode 100644
index 00000000..ed9269fd
--- /dev/null
+++ b/ccweb/ccweb/__init__.py
@@ -0,0 +1 @@
+"""CCWeb — React web gateway to Claude Code sessions via tmux."""
diff --git a/ccweb/backend/__init__.py b/ccweb/ccweb/backend/__init__.py
similarity index 100%
rename from ccweb/backend/__init__.py
rename to ccweb/ccweb/backend/__init__.py
diff --git a/ccweb/backend/config.py b/ccweb/ccweb/backend/config.py
similarity index 100%
rename from ccweb/backend/config.py
rename to ccweb/ccweb/backend/config.py
diff --git a/ccweb/backend/core/__init__.py b/ccweb/ccweb/backend/core/__init__.py
similarity index 100%
rename from ccweb/backend/core/__init__.py
rename to ccweb/ccweb/backend/core/__init__.py
diff --git a/ccweb/backend/core/hook.py b/ccweb/ccweb/backend/core/hook.py
similarity index 100%
rename from ccweb/backend/core/hook.py
rename to ccweb/ccweb/backend/core/hook.py
diff --git a/ccweb/backend/core/monitor_state.py b/ccweb/ccweb/backend/core/monitor_state.py
similarity index 100%
rename from ccweb/backend/core/monitor_state.py
rename to ccweb/ccweb/backend/core/monitor_state.py
diff --git a/ccweb/backend/core/session_monitor.py b/ccweb/ccweb/backend/core/session_monitor.py
similarity index 100%
rename from ccweb/backend/core/session_monitor.py
rename to ccweb/ccweb/backend/core/session_monitor.py
diff --git a/ccweb/backend/core/terminal_parser.py b/ccweb/ccweb/backend/core/terminal_parser.py
similarity index 100%
rename from ccweb/backend/core/terminal_parser.py
rename to ccweb/ccweb/backend/core/terminal_parser.py
diff --git a/ccweb/backend/core/tmux_manager.py b/ccweb/ccweb/backend/core/tmux_manager.py
similarity index 100%
rename from ccweb/backend/core/tmux_manager.py
rename to ccweb/ccweb/backend/core/tmux_manager.py
diff --git a/ccweb/backend/core/transcript_parser.py b/ccweb/ccweb/backend/core/transcript_parser.py
similarity index 100%
rename from ccweb/backend/core/transcript_parser.py
rename to ccweb/ccweb/backend/core/transcript_parser.py
diff --git a/ccweb/backend/core/utils.py b/ccweb/ccweb/backend/core/utils.py
similarity index 100%
rename from ccweb/backend/core/utils.py
rename to ccweb/ccweb/backend/core/utils.py
diff --git a/ccweb/backend/main.py b/ccweb/ccweb/backend/main.py
similarity index 99%
rename from ccweb/backend/main.py
rename to ccweb/ccweb/backend/main.py
index 275a11e3..bb1cae48 100644
--- a/ccweb/backend/main.py
+++ b/ccweb/ccweb/backend/main.py
@@ -181,7 +181,7 @@ def main() -> None:
 
     args = sys.argv[1:]
 
-    if not args or args[0] not in ("install", "hook"):
+    if not args:
         _cmd_serve()
     elif args[0] == "install":
         _cmd_install()
diff --git a/ccweb/backend/server.py b/ccweb/ccweb/backend/server.py
similarity index 99%
rename from ccweb/backend/server.py
rename to ccweb/ccweb/backend/server.py
index 2cbe2512..163d4505 100644
--- a/ccweb/backend/server.py
+++ b/ccweb/ccweb/backend/server.py
@@ -556,7 +556,8 @@ async def health() -> dict[str, Any]:
         return h.to_dict()
 
     # Serve static frontend files (production)
-    static_dir = Path(__file__).parent.parent / "frontend" / "dist"
+    # __file__ is ccweb/ccweb/backend/server.py → .parent.parent.parent = ccweb/
+    static_dir = Path(__file__).parent.parent.parent / "frontend" / "dist"
     if static_dir.exists():
         app.mount("/", StaticFiles(directory=str(static_dir), html=True))
 
diff --git a/ccweb/backend/session.py b/ccweb/ccweb/backend/session.py
similarity index 100%
rename from ccweb/backend/session.py
rename to ccweb/ccweb/backend/session.py
diff --git a/ccweb/backend/ui_parser.py b/ccweb/ccweb/backend/ui_parser.py
similarity index 100%
rename from ccweb/backend/ui_parser.py
rename to ccweb/ccweb/backend/ui_parser.py
diff --git a/ccweb/backend/ws_protocol.py b/ccweb/ccweb/backend/ws_protocol.py
similarity index 100%
rename from ccweb/backend/ws_protocol.py
rename to ccweb/ccweb/backend/ws_protocol.py
diff --git a/ccweb/pyproject.toml b/ccweb/pyproject.toml
index 14a19bcb..38a54cdd 100644
--- a/ccweb/pyproject.toml
+++ b/ccweb/pyproject.toml
@@ -2,7 +2,7 @@
 name = "ccweb"
 version = "0.1.0"
 description = "React web gateway to Claude Code sessions via tmux"
-readme = "README.md"
+readme = { text = "React web gateway to Claude Code sessions via tmux. See docs/ for full documentation.", content-type = "text/plain" }
 requires-python = ">=3.12"
 dependencies = [
     "fastapi>=0.115.0",
@@ -29,7 +29,7 @@ requires = ["hatchling"]
 build-backend = "hatchling.build"
 
 [tool.hatch.build.targets.wheel]
-packages = ["ccweb/backend"]
+packages = ["ccweb"]
 
 [tool.ruff]
 target-version = "py312"

From 611921e7ea676cc6b58b5a0d7b8f43c9fb4a67a0 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 11 Apr 2026 00:23:35 +0000
Subject: [PATCH 33/57] Fix silent message drops, grid reconnection, and send
 ACK

Found by fresh adversarial data flow review:

- Add debug logging when message routing skips a client due to missing
  window_state (previously silently continued, making dropped messages
  invisible)
- Clear _sent_grid_files on new WebSocket connection so reconnecting
  clients can see pending decision grid files
- Add send_ack response on successful text send so the frontend knows
  the message was delivered to Claude

https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw
---
 ccweb/ccweb/backend/server.py | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/ccweb/ccweb/backend/server.py b/ccweb/ccweb/backend/server.py
index 163d4505..a4999267 100644
--- a/ccweb/ccweb/backend/server.py
+++ b/ccweb/ccweb/backend/server.py
@@ -170,7 +170,14 @@ async def _handle_new_message(msg: NewMessage) -> None:
         # Check if this window's session matches the message's session
         # Use lookup (no auto-create) to avoid phantom state entries
         state = session_manager.lookup_window_state(window_id)
-        if not state or state.session_id != msg.session_id:
+        if not state:
+            logger.debug(
+                "No window_state for %s (client=%s), skipping message",
+                window_id,
+                client_id[:8],
+            )
+            continue
+        if state.session_id != msg.session_id:
             continue
 
         ws_msg = WsMessage(
@@ -325,7 +332,9 @@ async def _handle_ws_message(
             from .session import session_manager
 
             success, message = await session_manager.send_to_window(window_id, text)
-            if not success:
+            if success:
+                await ws.send_json({"type": "send_ack", "window_id": window_id})
+            else:
                 await ws.send_json(
                     WsError(code="send_failed", message=message).to_dict()
                 )
@@ -461,6 +470,9 @@ async def ws_endpoint(websocket: WebSocket) -> None:
     _clients[client_id] = websocket
     logger.info("WebSocket connected: %s", client_id)
 
+    # Clear sent-grid tracking so reconnecting clients can see pending grids
+    _sent_grid_files.clear()
+
     try:
         # Send health check
         health = await _build_health()

From f19177390739db0be504c23554e1c71663872aed Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 11 Apr 2026 00:25:35 +0000
Subject: [PATCH 34/57] Fix protocol completeness and silent error handling

Found by fresh adversarial protocol review:

- Add WsPong, WsHistory, WsSendAck dataclasses to ws_protocol.py,
  replacing hardcoded raw dicts in server.py
- Remove dead code: WsReplay and CLIENT_REPLAY_REQUEST (replay not
  implemented, deferred to v2)
- Replace all 5 silent `except Exception: pass` blocks with debug
  logging so dead WebSocket clients don't silently swallow errors
- Fix shutdown: await _status_task cancellation properly, clear
  module-level state (_clients, _client_bindings, _sent_grid_files)
  to prevent test pollution

Also confirmed by install/run review: all imports resolve, pip install
succeeds, entry point works, no remaining ccbot references in code paths.

https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw
---
 ccweb/ccweb/backend/server.py      | 30 +++++++++++++++-----------
 ccweb/ccweb/backend/ws_protocol.py | 34 ++++++++++++++++++++++++------
 2 files changed, 46 insertions(+), 18 deletions(-)

diff --git a/ccweb/ccweb/backend/server.py b/ccweb/ccweb/backend/server.py
index a4999267..bae20833 100644
--- a/ccweb/ccweb/backend/server.py
+++ b/ccweb/ccweb/backend/server.py
@@ -48,8 +48,11 @@
     WsDecisionGrid,
     WsError,
     WsHealth,
+    WsHistory,
     WsInteractiveUI,
     WsMessage,
+    WsPong,
+    WsSendAck,
     WsSessions,
     WsStatus,
 )
@@ -150,8 +153,8 @@ async def _broadcast_sessions() -> None:
     for ws in list(_clients.values()):
         try:
             await ws.send_json(msg)
-        except Exception:
-            pass
+        except Exception as e:
+            logger.debug("WebSocket send failed: %s", e)
 
 
 # ── Message callback (from SessionMonitor) ───────────────────────────────
@@ -190,8 +193,8 @@ async def _handle_new_message(msg: NewMessage) -> None:
         )
         try:
             await ws.send_json(ws_msg.to_dict())
-        except Exception:
-            pass
+        except Exception as e:
+            logger.debug("WebSocket send failed: %s", e)
 
 
 # ── Status polling ───────────────────────────────────────────────────────
@@ -307,7 +310,7 @@ async def _handle_ws_message(
     window_id = data.get("window_id", "")
 
     if msg_type == CLIENT_PING:
-        await ws.send_json({"type": "pong"})
+        await ws.send_json(WsPong().to_dict())
         return
 
     if msg_type == CLIENT_SWITCH_SESSION:
@@ -317,12 +320,7 @@ async def _handle_ws_message(
 
         messages, total = await session_manager.get_recent_messages(window_id)
         await ws.send_json(
-            {
-                "type": "history",
-                "window_id": window_id,
-                "messages": messages,
-                "total": total,
-            }
+            WsHistory(window_id=window_id, messages=messages, total=total).to_dict()
         )
         return
 
@@ -333,7 +331,7 @@ async def _handle_ws_message(
 
             success, message = await session_manager.send_to_window(window_id, text)
             if success:
-                await ws.send_json({"type": "send_ack", "window_id": window_id})
+                await ws.send_json(WsSendAck(window_id=window_id).to_dict())
             else:
                 await ws.send_json(
                     WsError(code="send_failed", message=message).to_dict()
@@ -545,8 +543,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
     # Shutdown
     if _status_task:
         _status_task.cancel()
+        try:
+            await _status_task
+        except asyncio.CancelledError:
+            pass
     if _monitor:
         _monitor.stop()
+    # Clear module-level state
+    _clients.clear()
+    _client_bindings.clear()
+    _sent_grid_files.clear()
     logger.info("CCWeb stopped")
 
 
diff --git a/ccweb/ccweb/backend/ws_protocol.py b/ccweb/ccweb/backend/ws_protocol.py
index 04c2163a..1ff60c3a 100644
--- a/ccweb/ccweb/backend/ws_protocol.py
+++ b/ccweb/ccweb/backend/ws_protocol.py
@@ -1,8 +1,8 @@
 """WebSocket protocol message types for CCWeb.
 
 Defines the bidirectional JSON message format between the FastAPI backend
-and the React frontend. Server→Client messages deliver Claude output,
-interactive UIs, status updates, and session lists. Client→Server messages
+and the React frontend. Server->Client messages deliver Claude output,
+interactive UIs, status updates, and session lists. Client->Server messages
 send user text, key presses, decision grid submissions, and session commands.
 
 All messages are JSON objects with a "type" field for dispatch.
@@ -110,11 +110,34 @@ def to_dict(self) -> dict[str, Any]:
 
 
 @dataclass
-class WsReplay:
-    """Message replay after client reconnection."""
+class WsPong:
+    """Keepalive response."""
 
-    type: str = "replay"
+    type: str = "pong"
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass
+class WsHistory:
+    """Message history for a session."""
+
+    type: str = "history"
+    window_id: str = ""
     messages: list[dict[str, Any]] = field(default_factory=list)
+    total: int = 0
+
+    def to_dict(self) -> dict[str, Any]:
+        return asdict(self)
+
+
+@dataclass
+class WsSendAck:
+    """Acknowledgment that text was sent to Claude."""
+
+    type: str = "send_ack"
+    window_id: str = ""
 
     def to_dict(self) -> dict[str, Any]:
         return asdict(self)
@@ -131,5 +154,4 @@ def to_dict(self) -> dict[str, Any]:
 CLIENT_KILL_SESSION = "kill_session"
 CLIENT_SWITCH_SESSION = "switch_session"
 CLIENT_GET_HISTORY = "get_history"
-CLIENT_REPLAY_REQUEST = "replay_request"
 CLIENT_PING = "ping"

From eb8ee6257371fcbb6786a340e84953284a6932f9 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 11 Apr 2026 01:29:48 +0000
Subject: [PATCH 35/57] Add React frontend (Phase 2-3): message stream,
 sessions, interactive UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

React + TypeScript + Vite + Tailwind frontend with:

Components:
- App.tsx: main layout with sidebar + content area
- SessionSidebar.tsx: session list, create/kill, health warnings
- MessageStream.tsx: styled message display with markdown rendering,
  auto-scroll, copy buttons on code blocks, role-based styling
- MessageInput.tsx: multi-line textarea, visible Submit button,
  Ctrl+Enter shortcut, Escape toolbar button
- InteractiveUI.tsx: renders AskUserQuestion as clickable option cards,
  PermissionPrompt as Allow/Deny buttons, ExitPlanMode as Proceed/Edit,
  with raw-text fallback + navigation keyboard for unknown UI types
- FilterBar.tsx: toggleable chips (All/Chat/No Thinking/Tools)
- ExpandableBlock.tsx: collapsible sections for thinking/tool results
- StatusBar.tsx: connection status indicator + Claude status text

Hooks:
- useWebSocket.ts: WebSocket connection with auto-reconnect (exponential
  backoff), 30s keepalive pings, typed message dispatch
- useSession.ts: session state, message history, filter logic

Protocol:
- protocol.ts: mirrors ws_protocol.py — all server/client message types

Config:
- Vite proxy: ws: true for WebSocket, /api proxy to FastAPI
- Dark terminal theme (Catppuccin-inspired)
- TypeScript strict mode, builds clean

https://claude.ai/code/session_01TxXyTqEAaG3ExRtaooMBKw
---
 ccweb/frontend/.gitignore                     |    2 +
 ccweb/frontend/index.html                     |   12 +
 ccweb/frontend/package-lock.json              | 2864 +++++++++++++++++
 ccweb/frontend/package.json                   |   26 +
 ccweb/frontend/src/App.tsx                    |  140 +
 .../src/components/ExpandableBlock.tsx        |   60 +
 ccweb/frontend/src/components/FilterBar.tsx   |   51 +
 .../frontend/src/components/InteractiveUI.tsx |  276 ++
 .../frontend/src/components/MessageInput.tsx  |  118 +
 .../frontend/src/components/MessageStream.tsx |  210 ++
 .../src/components/SessionSidebar.tsx         |  258 ++
 ccweb/frontend/src/components/StatusBar.tsx   |   45 +
 ccweb/frontend/src/components/types.ts        |    1 +
 ccweb/frontend/src/hooks/useSession.ts        |  158 +
 ccweb/frontend/src/hooks/useWebSocket.ts      |   91 +
 ccweb/frontend/src/index.css                  |   45 +
 ccweb/frontend/src/main.tsx                   |   10 +
 ccweb/frontend/src/protocol.ts                |  172 +
 ccweb/frontend/src/vite-env.d.ts              |    1 +
 ccweb/frontend/tsconfig.json                  |   21 +
 ccweb/frontend/tsconfig.tsbuildinfo           |    1 +
 ccweb/frontend/vite.config.ts                 |   19 +
 22 files changed, 4581 insertions(+)
 create mode 100644 ccweb/frontend/.gitignore
 create mode 100644 ccweb/frontend/index.html
 create mode 100644 ccweb/frontend/package-lock.json
 create mode 100644 ccweb/frontend/package.json
 create mode 100644 ccweb/frontend/src/App.tsx
 create mode 100644 ccweb/frontend/src/components/ExpandableBlock.tsx
 create mode 100644 ccweb/frontend/src/components/FilterBar.tsx
 create mode 100644 ccweb/frontend/src/components/InteractiveUI.tsx
 create mode 100644 ccweb/frontend/src/components/MessageInput.tsx
 create mode 100644 ccweb/frontend/src/components/MessageStream.tsx
 create mode 100644 ccweb/frontend/src/components/SessionSidebar.tsx
 create mode 100644 ccweb/frontend/src/components/StatusBar.tsx
 create mode 100644 ccweb/frontend/src/components/types.ts
 create mode 100644 ccweb/frontend/src/hooks/useSession.ts
 create mode 100644 ccweb/frontend/src/hooks/useWebSocket.ts
 create mode 100644 ccweb/frontend/src/index.css
 create mode 100644 ccweb/frontend/src/main.tsx
 create mode 100644 ccweb/frontend/src/protocol.ts
 create mode 100644 ccweb/frontend/src/vite-env.d.ts
 create mode 100644 ccweb/frontend/tsconfig.json
 create mode 100644 ccweb/frontend/tsconfig.tsbuildinfo
 create mode 100644 ccweb/frontend/vite.config.ts

diff --git a/ccweb/frontend/.gitignore b/ccweb/frontend/.gitignore
new file mode 100644
index 00000000..b9470778
--- /dev/null
+++ b/ccweb/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/ccweb/frontend/index.html b/ccweb/frontend/index.html
new file mode 100644
index 00000000..b98c287f
--- /dev/null
+++ b/ccweb/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    CCWeb
+  
+  
+    
+ + + diff --git a/ccweb/frontend/package-lock.json b/ccweb/frontend/package-lock.json new file mode 100644 index 00000000..b921fc65 --- /dev/null +++ b/ccweb/frontend/package-lock.json @@ -0,0 +1,2864 @@ +{ + "name": "frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ccweb/frontend/package.json b/ccweb/frontend/package.json new file mode 100644 index 00000000..348b84e2 --- /dev/null +++ b/ccweb/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "ccweb-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } +} diff --git a/ccweb/frontend/src/App.tsx b/ccweb/frontend/src/App.tsx new file mode 100644 index 00000000..6df02c15 --- /dev/null +++ b/ccweb/frontend/src/App.tsx @@ -0,0 +1,140 @@ +import { useCallback } from "react"; +import { useWebSocket } from "./hooks/useWebSocket"; +import { filterMessages, useSession } from "./hooks/useSession"; +import { SessionSidebar } from "./components/SessionSidebar"; +import { MessageStream } from "./components/MessageStream"; +import { MessageInput } from "./components/MessageInput"; +import { StatusBar } from "./components/StatusBar"; +import { InteractiveUI } from "./components/InteractiveUI"; +import { FilterBar } from "./components/FilterBar"; +import type { ClientSendKey } from "./protocol"; + +function App() { + const { + sessions, + activeWindowId, + messages, + statusText, + health, + interactiveUI, + filter, + setFilter, + handleServerMessage, + setActiveWindowId, + } = useSession(); + + const { status, send } = useWebSocket(handleServerMessage); + + const handleSelectSession = useCallback( + (windowId: string) => { + setActiveWindowId(windowId); + send({ type: "switch_session", window_id: windowId }); + }, + [send, setActiveWindowId], + ); + + const handleCreateSession = useCallback( + (workDir: string, name?: string) => { + send({ type: "create_session", work_dir: workDir, name }); + }, + [send], + ); + + const handleKillSession = useCallback( + (windowId: string) => { + send({ type: "kill_session", window_id: windowId }); + if (activeWindowId === windowId) { + setActiveWindowId(null); + } + }, + [send, activeWindowId, setActiveWindowId], + ); + + const handleSendText = useCallback( + (text: string) => { + if (!activeWindowId) return; + send({ type: "send_text", window_id: activeWindowId, text }); + }, + [send, activeWindowId], + ); + + const handleSendKey = useCallback( + (key: ClientSendKey["key"]) => { + if (!activeWindowId) return; + send({ type: "send_key", window_id: activeWindowId, key }); + }, + [send, activeWindowId], + ); + + const handleEscape = useCallback(() => { + if (!activeWindowId) return; + send({ type: "send_key", window_id: activeWindowId, key: "Escape" }); + }, [send, activeWindowId]); + + const filteredMessages = filterMessages(messages, filter); + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {activeWindowId ? ( + <> + + + {interactiveUI && ( + + )} + + + + ) : ( +
+
CCWeb
+
Select a session or create a new one
+
+ )} +
+
+ ); +} + +export default App; diff --git a/ccweb/frontend/src/components/ExpandableBlock.tsx b/ccweb/frontend/src/components/ExpandableBlock.tsx new file mode 100644 index 00000000..cbe0bfb5 --- /dev/null +++ b/ccweb/frontend/src/components/ExpandableBlock.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +interface ExpandableBlockProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +export function ExpandableBlock({ + title, + children, + defaultExpanded = false, +}: ExpandableBlockProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ + {expanded && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/ccweb/frontend/src/components/FilterBar.tsx b/ccweb/frontend/src/components/FilterBar.tsx new file mode 100644 index 00000000..f99ace71 --- /dev/null +++ b/ccweb/frontend/src/components/FilterBar.tsx @@ -0,0 +1,51 @@ +import type { MessageFilter } from "../hooks/useSession"; + +interface FilterBarProps { + filter: MessageFilter; + onFilterChange: (f: MessageFilter) => void; +} + +const FILTERS: Array<{ value: MessageFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "chat", label: "Chat" }, + { value: "no_thinking", label: "No Thinking" }, + { value: "tools", label: "Tools" }, +]; + +export function FilterBar({ filter, onFilterChange }: FilterBarProps) { + return ( +
+ {FILTERS.map((f) => ( + + ))} +
+ ); +} diff --git a/ccweb/frontend/src/components/InteractiveUI.tsx b/ccweb/frontend/src/components/InteractiveUI.tsx new file mode 100644 index 00000000..1ba98a9a --- /dev/null +++ b/ccweb/frontend/src/components/InteractiveUI.tsx @@ -0,0 +1,276 @@ +import type { WsInteractiveUI, ClientSendKey } from "../protocol"; + +interface InteractiveUIProps { + ui: WsInteractiveUI; + onSendKey: (key: ClientSendKey["key"]) => void; +} + +function StructuredUI({ + ui, + onSendKey, +}: { + ui: WsInteractiveUI; + onSendKey: (key: ClientSendKey["key"]) => void; +}) { + const data = ui.structured!; + + if (data.ui_name === "AskUserQuestion") { + return ( +
+ {data.options.map((opt) => ( + + ))} + +
+ ); + } + + if (data.ui_name === "ExitPlanMode") { + return ( +
+ {data.description && ( +
+ {data.description} +
+ )} +
+ + +
+
+ ); + } + + if ( + data.ui_name === "PermissionPrompt" || + data.ui_name === "BashApproval" + ) { + return ( +
+
+ {data.description} +
+ {data.command && ( +
+            {data.command}
+          
+ )} +
+ + +
+
+ ); + } + + // Fallback for unknown structured UI types + return ; +} + +function RawUI({ + content, + onSendKey, +}: { + content: string; + onSendKey: (key: ClientSendKey["key"]) => void; +}) { + return ( +
+
+        {content}
+      
+
+ {( + [ + ["Space", "\u2423 Space"], + ["Up", "\u2191"], + ["Tab", "\u21E5 Tab"], + ["Left", "\u2190"], + ["Down", "\u2193"], + ["Right", "\u2192"], + ["Escape", "Esc"], + ["Enter", "\u23CE Enter"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ ); +} + +export function InteractiveUI({ ui, onSendKey }: InteractiveUIProps) { + return ( +
+
+ {ui.ui_name} +
+ {ui.structured ? ( + + ) : ( + + )} +
+ ); +} diff --git a/ccweb/frontend/src/components/MessageInput.tsx b/ccweb/frontend/src/components/MessageInput.tsx new file mode 100644 index 00000000..56fca277 --- /dev/null +++ b/ccweb/frontend/src/components/MessageInput.tsx @@ -0,0 +1,118 @@ +import { useCallback, useRef, useState } from "react"; + +interface MessageInputProps { + onSend: (text: string) => void; + onEscape: () => void; + disabled?: boolean; +} + +export function MessageInput({ onSend, onEscape, disabled }: MessageInputProps) { + const [text, setText] = useState(""); + const textareaRef = useRef(null); + + const handleSubmit = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + onSend(trimmed); + setText(""); + // Reset textarea height + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, [text, onSend]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ctrl/Cmd+Enter to submit + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + // Auto-resize textarea + const handleInput = (e: React.ChangeEvent) => { + setText(e.target.value); + const el = e.target; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 200) + "px"; + }; + + return ( +
+ {/* Toolbar */} +
+ +
+ + {/* Input area */} +
+