Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ ALLOWED_USERS=your_telegram_user_id
| `CLAUDE_COMMAND` | `claude` | Command to run in new windows |
| `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds |
| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser |
| `CCBOT_AUTO_TRUST_DIRS` | `true` | Mark the project trusted in `~/.claude.json` before launching `claude`, so the workspace trust dialog doesn't block new sessions |
| `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API base URL (for proxies or compatible APIs) |

Expand Down
10 changes: 10 additions & 0 deletions src/ccbot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
filters,
)

from .claude_config import ensure_trusted_directory
from .config import config
from .handlers.callback_data import (
CB_ASK_DOWN,
Expand Down Expand Up @@ -1034,6 +1035,9 @@ async def _create_and_bind_window(
assert isinstance(query, CallbackQuery)
assert isinstance(user, User)

if config.auto_trust_dirs:
await asyncio.to_thread(ensure_trusted_directory, selected_path)

success, message, created_wname, created_wid = await tmux_manager.create_window(
selected_path, resume_session_id=resume_session_id
)
Expand All @@ -1047,6 +1051,12 @@ async def _create_and_bind_window(
pending_thread_id,
resume_session_id,
)
# Wait for Claude Code's input box first. This answers the workspace
# trust dialog if it still appears; the SessionStart hook only fires
# once Claude is past it, and a pending message must not be typed
# into the dialog (Enter on the default option exits Claude).
await tmux_manager.wait_for_claude_ready(created_wid, timeout=30.0)

# Wait for Claude Code's SessionStart hook to register in session_map.
# Resume sessions take longer to start (loading session state), so use
# a longer timeout to avoid silently dropping messages.
Expand Down
101 changes: 101 additions & 0 deletions src/ccbot/claude_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Helpers for Claude Code's own config file (~/.claude.json).

Pre-trusts a working directory before launching `claude` in it so the
interactive "Quick safety check … Yes, I trust this folder" dialog never
appears. Since ~2.1.260 that dialog defaults to "No, exit", which left
bot-created sessions stuck at the prompt with the SessionStart hook never
firing.

Key functions:
- claude_config_file(): path of ~/.claude.json (honours CLAUDE_CONFIG_DIR)
- trust_key_for(work_dir): the projects[] key Claude uses (git root or dir)
- ensure_trusted_directory(work_dir): set hasTrustDialogAccepted=true
"""

import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Any

from .utils import atomic_write_json

logger = logging.getLogger(__name__)


def claude_config_file() -> Path:
"""Location of Claude Code's user config JSON."""
config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "")
if config_dir:
return Path(config_dir).expanduser() / ".claude.json"
return Path.home() / ".claude.json"


def trust_key_for(work_dir: str | Path) -> str:
"""Return the ``projects`` key Claude Code trusts ``work_dir`` under.

Trust is keyed on the git repository root (main checkout root for
worktrees); outside a repo, on the directory itself.
"""
path = Path(work_dir).expanduser().resolve()
try:
result = subprocess.run(
["git", "-C", str(path), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0 and result.stdout.strip():
return str(Path(result.stdout.strip()).resolve())
except (OSError, subprocess.SubprocessError):
pass
return str(path)


def ensure_trusted_directory(work_dir: str | Path) -> bool:
"""Mark ``work_dir`` as trusted in ~/.claude.json.

Returns True when the directory is trusted after the call (already
trusted or newly written), False when the config could not be updated
(missing/malformed file is left untouched — the pane-level dialog
handling in TmuxManager remains as the fallback).
"""
key = trust_key_for(work_dir)
cfg_path = claude_config_file()
if not cfg_path.exists():
logger.info("No %s yet; skipping trust pre-accept for %s", cfg_path, key)
return False
try:
data: Any = json.loads(cfg_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
logger.warning("Cannot read %s (%s); not touching it", cfg_path, e)
return False
if not isinstance(data, dict):
logger.warning("%s is not a JSON object; not touching it", cfg_path)
return False

projects = data.get("projects")
if projects is None:
projects = data["projects"] = {}
if not isinstance(projects, dict):
logger.warning("%s: 'projects' is not an object; not touching it", cfg_path)
return False

entry = projects.get(key)
if isinstance(entry, dict) and entry.get("hasTrustDialogAccepted") is True:
return True
if not isinstance(entry, dict):
entry = projects[key] = {}
entry["hasTrustDialogAccepted"] = True

try:
mode = cfg_path.stat().st_mode & 0o777
atomic_write_json(cfg_path, data)
os.chmod(cfg_path, mode)
except OSError as e:
logger.warning("Failed to write %s: %s", cfg_path, e)
return False
logger.info("Pre-trusted %s in %s", key, cfg_path)
return True
6 changes: 6 additions & 0 deletions src/ccbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ def __init__(self) -> None:
os.getenv("CCBOT_SHOW_TOOL_CALLS", "true").lower() != "false"
)

# Pre-trust directories in ~/.claude.json before launching claude,
# so Claude Code's workspace trust dialog doesn't block new sessions
self.auto_trust_dirs = (
os.getenv("CCBOT_AUTO_TRUST_DIRS", "true").lower() != "false"
)

# Show hidden (dot) directories in directory browser
self.show_hidden_dirs = (
os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true"
Expand Down
15 changes: 14 additions & 1 deletion src/ccbot/handlers/status_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
from telegram.error import BadRequest

from ..session import session_manager
from ..terminal_parser import is_interactive_ui, parse_status_line
from ..terminal_parser import (
AUTO_ANSWER_DIALOGS,
extract_interactive_content,
is_interactive_ui,
parse_status_line,
)
from ..tmux_manager import tmux_manager
from .interactive_ui import (
clear_interactive_msg,
Expand Down Expand Up @@ -90,6 +95,14 @@ async def update_status_message(
# Clear stale interactive mode
await clear_interactive_msg(user_id, bot, thread_id)

# Startup dialogs (workspace trust) are answered by the bot itself
# rather than rendered as a keyboard.
if should_check_new_ui:
ui = extract_interactive_content(pane_text)
if ui is not None and ui.name in AUTO_ANSWER_DIALOGS:
await tmux_manager.auto_answer_dialog(window_id, pane_text)
return

# 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):
Expand Down
78 changes: 78 additions & 0 deletions src/ccbot/terminal_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ class UIPattern:
# ── UI pattern definitions (order matters — first match wins) ────────────

UI_PATTERNS: list[UIPattern] = [
UIPattern(
# Workspace trust dialog shown on first launch in a directory.
# Since ~2.1.260 the options are unnumbered and "No, exit" is the
# default, so an unanswered or blindly-confirmed dialog ends the session.
name="TrustDialog",
top=(
re.compile(r"^\s*Quick safety check"),
re.compile(r"^\s*Do you trust the files in this folder"),
),
bottom=(re.compile(r"Enter to confirm"),),
),
UIPattern(
name="ExitPlanMode",
top=(
Expand Down Expand Up @@ -118,6 +129,14 @@ class UIPattern:
]


# Dialogs the bot answers itself: name -> text of the option to select.
AUTO_ANSWER_DIALOGS: dict[str, str] = {
"TrustDialog": "Yes, I trust",
}

_RE_MENU_CURSOR = re.compile(r"^\s*❯\s*\S")


# ── Post-processing ──────────────────────────────────────────────────────

_RE_LONG_DASH = re.compile(r"^─{5,}$")
Expand Down Expand Up @@ -193,6 +212,65 @@ def is_interactive_ui(pane_text: str) -> bool:
return extract_interactive_content(pane_text) is not None


def find_menu_option(pane_text: str, needle: str) -> tuple[int, int] | None:
"""Locate the highlighted (``❯``) line and the option containing ``needle``.

Returns ``(cursor_idx, option_idx)`` line indices; pressing Down
``option_idx - cursor_idx`` times moves the cursor onto the option.
The option is searched from the bottom (dialogs render at the end of the
pane) and the cursor is the ``❯`` nearest to it, so an earlier shell
prompt such as ``❯ claude`` is never mistaken for the menu cursor.
"""
lines = pane_text.split("\n")
option_idx = next(
(i for i in range(len(lines) - 1, -1, -1) if needle in lines[i]), None
)
if option_idx is None:
return None
lo, hi = max(0, option_idx - 10), min(len(lines), option_idx + 11)
cursor_idx = min(
(i for i in range(lo, hi) if _RE_MENU_CURSOR.match(lines[i])),
key=lambda i: abs(i - option_idx),
default=None,
)
if cursor_idx is None:
return None
return cursor_idx, option_idx


def _is_separator(line: str) -> bool:
"""A chrome border line.

Named sessions (--resume <name>, --name, /rename) put the name in the
input box's top border: "──────── my-session ─" (one trailing dash).
"""
stripped = line.strip()
if len(stripped) < 20 or not stripped.startswith("────"):
return False
return stripped.endswith("─") and stripped.count("─") / len(stripped) >= 0.6


def is_prompt_ready(pane_text: str) -> bool:
"""True when Claude Code shows its idle input box.

Layout at the bottom of the pane::

──────────────── (separator)
❯ … (input line)
──────────────── (separator)
⏵⏵ … (footer)
"""
if not pane_text:
return False
tail = pane_text.rstrip().split("\n")[-8:]
for i in range(len(tail) - 2):
if not _is_separator(tail[i]) or not tail[i + 1].lstrip().startswith("❯"):
continue
if any(_is_separator(tail[j]) for j in range(i + 2, min(i + 5, len(tail)))):
return True
return False


# ── Status line parsing ─────────────────────────────────────────────────

# Spinner characters Claude Code uses in its status line
Expand Down
66 changes: 66 additions & 0 deletions src/ccbot/tmux_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@
import asyncio
import logging
import re
import time
from dataclasses import dataclass
from pathlib import Path

import libtmux

from .config import SENSITIVE_ENV_VARS, config
from .terminal_parser import (
AUTO_ANSWER_DIALOGS,
extract_interactive_content,
find_menu_option,
is_prompt_ready,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -229,6 +236,65 @@ def _sync_capture() -> str | None:

return await asyncio.to_thread(_sync_capture)

async def auto_answer_dialog(self, window_id: str, pane_text: str) -> str | None:
"""Answer a startup dialog the bot handles itself (workspace trust).

Moves the ``❯`` cursor onto the wanted option with Up/Down, re-captures
to verify it landed there, then presses Enter. Never types a digit —
the option order has changed between Claude Code versions.

Returns the dialog name when answered, else None.
"""
ui = extract_interactive_content(pane_text)
if ui is None or ui.name not in AUTO_ANSWER_DIALOGS:
return None
needle = AUTO_ANSWER_DIALOGS[ui.name]
offsets = find_menu_option(pane_text, needle)
if offsets is None:
logger.warning("%s in %s: option %r not found", ui.name, window_id, needle)
return None
cursor, target = offsets
steps = target - cursor
key = "Down" if steps > 0 else "Up"
for _ in range(abs(steps)):
await self.send_keys(window_id, key, enter=False, literal=False)
await asyncio.sleep(0.15)
if steps:
await asyncio.sleep(0.3)
check = find_menu_option(await self.capture_pane(window_id) or "", needle)
if check is None or check[0] != check[1]:
logger.warning(
"%s in %s: cursor did not reach %r, not confirming",
ui.name,
window_id,
needle,
)
return None
await self.send_keys(window_id, "Enter", enter=False, literal=False)
logger.info("Answered %s in %s with %r", ui.name, window_id, needle)
return ui.name

async def wait_for_claude_ready(
self, window_id: str, timeout: float = 30.0
) -> bool:
"""Wait until Claude Code shows its input box, answering startup dialogs.

Returns False on timeout (the caller still proceeds; this only avoids
typing the first message into a dialog or a half-started TUI).
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
pane = await self.capture_pane(window_id)
if pane:
if is_prompt_ready(pane):
return True
if await self.auto_answer_dialog(window_id, pane):
await asyncio.sleep(1.0)
continue
await asyncio.sleep(0.5)
logger.warning("Claude Code not ready in %s after %.0fs", window_id, timeout)
return False

async def send_keys(
self, window_id: str, text: str, enter: bool = True, literal: bool = True
) -> bool:
Expand Down
24 changes: 24 additions & 0 deletions tests/ccbot/fixtures/panes/ready_bypass.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

▐▛███▛█ Claude Code v2.1.272
▝▜██████▀ Opus 5 with high effort · Claude Pro
▝▝ ▝▝ /private/tmp















● high · /effort
────────────────────────────────────────────────────────────────────────────────
❯ 
────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
Loading
Loading