Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
512bd19
Add CCBOT_BROWSE_ROOT config for directory browser start path
claude Feb 28, 2026
b9d0925
Merge pull request #1 from JanusMarko/claude/add-browse-root-config-5…
JanusMarko Feb 28, 2026
8eb53bf
Fix inconsistent Path.cwd() fallbacks in directory browser callbacks
claude Feb 28, 2026
f2f765e
Merge pull request #2 from JanusMarko/claude/add-browse-root-config-5…
JanusMarko Feb 28, 2026
2f1729e
Fix misc bugs: asyncio deprecation, double stat, missing /kill handler
claude Feb 28, 2026
bdcde13
Merge pull request #3 from JanusMarko/claude/add-browse-root-config-5…
JanusMarko Feb 28, 2026
22b75be
Fix duplicate Telegram messages for interactive UI prompts
claude Mar 1, 2026
1baaad8
Fix iter_thread_bindings RuntimeError: rename to all_thread_bindings …
claude Mar 1, 2026
fcb9207
Replace blocking queue.join() with enqueue_callable for interactive UI
claude Mar 1, 2026
e631f73
Replace destructive unpin_all topic probe with send_chat_action
claude Mar 1, 2026
9f7cd6e
Add RetryAfter retry loop with callable factory fix
claude Mar 1, 2026
a845d39
Remove mtime cache, use size-only fast path for file change detection
claude Mar 1, 2026
1a2bf77
Move save_if_dirty after message delivery for at-least-once semantics
claude Mar 1, 2026
ea895e2
Clean up _pending_tools when sessions are removed
claude Mar 1, 2026
0f701c0
Fix pending message loss when send_to_window fails
claude Mar 1, 2026
922cb45
Pass message_thread_id to send_chat_action for forum topics
claude Mar 1, 2026
7cf1210
Fix overly broad exception handling in handle_interactive_ui
claude Mar 2, 2026
8583d28
Add generation counter to prevent stale interactive UI callables
claude Mar 2, 2026
5c2033f
Add clarifying comment for fresh snapshot in status_poll_loop
claude Mar 2, 2026
5fa7ae4
Document intentionally ignored wait_for_session_map_entry return value
claude Mar 2, 2026
ff0ddf3
Fix screenshot refresh showing broken preview by switching to photo m…
claude Mar 2, 2026
f424a79
Prevent sending user input to shell when Claude Code has exited
claude Mar 4, 2026
cb455b2
Auto-resume Claude Code when pane drops to shell
claude Mar 4, 2026
f49960a
Optimize tmux performance with list_windows cache and unified capture…
claude Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │ │
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/message-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
202 changes: 149 additions & 53 deletions src/ccbot/bot.py

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/ccbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,23 @@ 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:
os.environ.pop(var, 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:
Expand Down
2 changes: 1 addition & 1 deletion src/ccbot/handlers/directory_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
103 changes: 96 additions & 7 deletions src/ccbot/handlers/interactive_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
"""

import logging
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
Expand Down Expand Up @@ -45,6 +47,21 @@
# 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

# 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."""
Expand All @@ -55,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:
Expand Down Expand Up @@ -145,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:
Expand Down Expand Up @@ -202,14 +245,54 @@ 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

# 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
Expand All @@ -222,7 +305,11 @@ 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)
return False
if sent:
Expand All @@ -241,6 +328,8 @@ async def clear_interactive_msg(
ikey = (user_id, thread_id or 0)
msg_id = _interactive_msgs.pop(ikey, None)
_interactive_mode.pop(ikey, None)
_last_interactive_send.pop(ikey, None)
_next_generation(ikey)
logger.debug(
"Clear interactive msg: user=%d, thread=%s, msg_id=%s",
user_id,
Expand Down
Loading
Loading