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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Per-conversation Claude effort**: `/effort low|medium|high|xhigh|max` sets the Agent SDK's reasoning effort for the current conversation, `/effort default` restores the SDK default, and `/status` shows the active value. The override reaches text, document, photo, voice, continue and quick-action runs in both agentic and classic mode, including project-topic state (#233)

### Fixed
- **The Claude review workflow posts one review per pull request instead of one per push**: the job reruns on every `synchronize` and posted a fresh comment each time, so #236 collected nine full reviews in four and a half hours, each restating what the last had already settled. `use_sticky_comment` was set but does nothing here — it only applies to the action's tag mode, and this workflow supplies `prompt`, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. The prompt now edits its own previous comment with `gh pr comment --edit-last --create-if-none`, so the pull request carries one review at the current head and GitHub keeps the superseded text in the comment's edit history. The inert input is removed rather than left to look load-bearing
- **The review reports only what should block the merge**: most of the length of those nine reviews was praise, an account of what had been checked, and cosmetic nits ("after 1 turns"), and every nit drew another push, which triggered another review — that loop, not the reviewing, was the spam. The prompt now names what qualifies (a security regression, a bug, an untested behaviour change, a missing setting or CHANGELOG entry) and rules out the rest, including anything `black`, `isort` or `flake8` already gates, and findings that cannot be confirmed from the code. It also reads its own previous review first so it does not repeat itself, but what settles a finding is the code at the current head rather than a reply claiming a fix: the replies are contributor-authored and untrusted like the rest of the pull request, so an earlier finding is re-checked against the diff and raised again unchanged when the code does not carry the claimed fix
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Webhook POST /webhooks/{provider} -> Signature verification -> Deduplication
-> NotificationService -> Rate-limited Telegram delivery
```

**Classic mode** (`AGENTIC_MODE=false`): Same middleware chain, but routes through full command/message handlers in `src/bot/handlers/` with 13 commands and inline keyboards.
**Classic mode** (`AGENTIC_MODE=false`): Same middleware chain, but routes through full command/message handlers in `src/bot/handlers/` with 15 commands and inline keyboards.

### Dependency Injection

Expand Down Expand Up @@ -130,7 +130,7 @@ All datetimes use timezone-aware UTC: `datetime.now(UTC)` (not `datetime.utcnow(

### Agentic mode

Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/repo`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. To add a new command:
Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/effort`, `/repo`, `/restart`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. To add a new command:

1. Add handler function in `src/bot/orchestrator.py`
2. Register in `MessageOrchestrator._register_agentic_handlers()`
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ The bot supports two interaction modes:

The default conversational mode. Just talk to Claude naturally -- no special commands required.

**Commands:** `/start`, `/new`, `/status`, `/verbose`, `/repo`
**Commands:** `/start`, `/new`, `/status`, `/verbose`, `/effort`, `/repo`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`

```
Expand Down Expand Up @@ -129,6 +129,10 @@ Use `/verbose 0|1|2` to control how much background activity is shown:
| **1** (normal, default) | Tool names + reasoning snippets in real-time |
| **2** (detailed) | Tool names with inputs + longer reasoning text |

Use `/effort low|medium|high|xhigh|max` to set Claude's reasoning effort for
the current conversation. `/effort` shows the current value, and
`/effort default` returns to the SDK default.

#### GitHub Workflow

Claude Code already knows how to use `gh` CLI and `git`. Authenticate on your server with `gh auth login`, then work with repos conversationally:
Expand All @@ -155,9 +159,9 @@ Use `/repo` to list cloned repos in your workspace, or `/repo <name>` to switch

### Classic Mode

Set `AGENTIC_MODE=false` to enable the full 13-command terminal-like interface with directory navigation, inline keyboards, quick actions, git integration, and session export.
Set `AGENTIC_MODE=false` to enable the full terminal-like interface with directory navigation, inline keyboards, quick actions, git integration, and session export.

**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git`
**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/effort`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git`
If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`

```
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ AUDIT_LOG_RETENTION_DAYS=365 # Days to keep audit logs

```bash
# Agentic mode (default: true)
# true = conversational mode with 3 commands (/start, /new, /status)
# false = classic terminal mode with 13 commands and inline keyboards
# true = conversational mode with a compact command set
# false = classic terminal mode with navigation and inline keyboards
AGENTIC_MODE=true
```

Expand Down
2 changes: 1 addition & 1 deletion docs/project-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Project Description

A Telegram bot that provides remote access to Claude Code, allowing developers to interact with their projects from anywhere. The default interaction model is **agentic mode** -- a conversational interface where users chat naturally with Claude. A classic terminal-like mode with 13 commands is also available.
A Telegram bot that provides remote access to Claude Code, allowing developers to interact with their projects from anywhere. The default interaction model is **agentic mode** -- a conversational interface where users chat naturally with Claude. A classic terminal-like mode with navigation and inline keyboards is also available.

## Core Objectives

Expand Down
8 changes: 7 additions & 1 deletion src/bot/handlers/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ...config.settings import Settings
from ...security.audit import AuditLogger
from ...security.validators import SecurityValidator
from ..utils.effort import get_effort
from ..utils.html_format import escape_html

logger = structlog.get_logger()
Expand Down Expand Up @@ -565,6 +566,7 @@ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) ->
working_directory=current_dir,
user_id=user_id,
session_id=claude_session_id,
effort=get_effort(context),
)
else:
# No session in context, try to find the most recent session
Expand All @@ -578,6 +580,7 @@ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) ->
user_id=user_id,
working_directory=current_dir,
prompt=None, # No prompt = use --continue
effort=get_effort(context),
)

if claude_response:
Expand Down Expand Up @@ -920,7 +923,10 @@ async def handle_quick_action_callback(

# Run the action through Claude
claude_response = await claude_integration.run_command(
prompt=action.prompt, working_directory=current_dir, user_id=user_id
prompt=action.prompt,
working_directory=current_dir,
user_id=user_id,
effort=get_effort(context),
)

if claude_response:
Expand Down
55 changes: 54 additions & 1 deletion src/bot/handlers/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import signal
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from typing import Optional, cast

import structlog
from claude_agent_sdk import EffortLevel
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes

Expand All @@ -16,6 +17,7 @@
from ...security.audit import AuditLogger
from ...security.validators import SecurityValidator
from ...storage.models import SessionModel
from ..utils.effort import EFFORT_LEVELS, get_effort, set_effort
from ..utils.html_format import escape_html

logger = structlog.get_logger()
Expand Down Expand Up @@ -121,6 +123,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
f"• <code>/cd &lt;dir&gt;</code> - Change directory\n"
f"• <code>/projects</code> - Show available projects\n"
f"• <code>/status</code> - Show session status\n"
f"• <code>/effort [level]</code> - Show or set reasoning effort\n"
f"• <code>/actions</code> - Show quick actions\n"
f"• <code>/git</code> - Git repository commands\n\n"
f"<b>Quick Start:</b>\n"
Expand Down Expand Up @@ -172,6 +175,7 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
"• <code>/continue [message]</code> - Explicitly continue last session\n"
"• <code>/end</code> - End current session and clear context\n"
"• <code>/status</code> - Show session and usage status\n"
"• <code>/effort [level]</code> - Show or set reasoning effort\n"
"• <code>/export</code> - Export session history\n"
"• <code>/actions</code> - Show context-aware quick actions\n"
"• <code>/git</code> - Git repository information\n\n"
Expand Down Expand Up @@ -205,6 +209,52 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
await update.message.reply_text(help_text, parse_mode="HTML")


async def effort_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show or set the SDK effort override for the current conversation."""
args = list(context.args or [])
current = get_effort(context)

if not args:
value = current or "SDK default"
await update.message.reply_text(
f"⚙️ <b>Claude Effort</b>: <code>{value}</code>\n\n"
"Usage: <code>/effort low|medium|high|xhigh|max|default</code>",
parse_mode="HTML",
)
return

requested = args[0].lower()
valid_values = (*EFFORT_LEVELS, "default")
if len(args) != 1 or requested not in valid_values:
await update.message.reply_text(
"❌ <b>Invalid effort level</b>\n\n"
"Usage: <code>/effort low|medium|high|xhigh|max|default</code>",
parse_mode="HTML",
)
return

if requested == "default":
set_effort(context, None)
display = "SDK default"
else:
set_effort(context, cast(EffortLevel, requested))
display = requested

await update.message.reply_text(
f"✅ Claude effort set to <code>{display}</code> for this conversation.",
parse_mode="HTML",
)

audit_logger: AuditLogger = context.bot_data.get("audit_logger")
if audit_logger:
await audit_logger.log_command(
user_id=update.effective_user.id,
command="effort",
args=args,
success=True,
)


async def sync_threads(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Synchronize project topics in the configured forum chat."""
settings: Settings = context.bot_data["settings"]
Expand Down Expand Up @@ -401,6 +451,7 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -
working_directory=current_dir,
user_id=user_id,
session_id=claude_session_id,
effort=get_effort(context),
)
else:
# No session in context, try to find the most recent session
Expand All @@ -415,6 +466,7 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -
user_id=user_id,
working_directory=current_dir,
prompt=prompt or default_prompt,
effort=get_effort(context),
)

if claude_response:
Expand Down Expand Up @@ -910,6 +962,7 @@ async def session_status(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"",
f"📂 Directory: <code>{relative_path}/</code>",
f"🤖 Claude Session: {'✅ Active' if claude_session_id else '❌ None'}",
f"⚙️ Effort: <code>{get_effort(context) or 'default'}</code>",
usage_info.rstrip(),
f"🕐 Last Update: {update.message.date.strftime('%H:%M:%S UTC')}",
]
Expand Down
5 changes: 5 additions & 0 deletions src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ...security.audit import AuditLogger
from ...security.rate_limiter import RateLimiter
from ...security.validators import SecurityValidator
from ..utils.effort import get_effort
from ..utils.html_format import escape_html
from ..utils.image_extractor import (
ImageAttachment,
Expand Down Expand Up @@ -393,6 +394,7 @@ async def stream_handler(update_obj):
session_id=session_id,
on_stream=stream_handler,
force_new=force_new,
effort=get_effort(context),
)

# New session created successfully — clear the one-shot flag
Expand Down Expand Up @@ -818,6 +820,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
effort=get_effort(context),
)

# Update session ID
Expand Down Expand Up @@ -945,6 +948,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
effort=get_effort(context),
)

# Update session ID
Expand Down Expand Up @@ -1073,6 +1077,7 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
working_directory=current_dir,
user_id=user_id,
session_id=session_id,
effort=get_effort(context),
)

context.user_data["claude_session_id"] = claude_response.session_id
Expand Down
25 changes: 20 additions & 5 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Message orchestrator — single entry point for all Telegram updates.

Routes messages based on agentic vs classic mode. In agentic mode, provides
a minimal conversational interface (3 commands, no inline keyboards). In
classic mode, delegates to existing full-featured handlers.
a minimal conversational interface without inline keyboards. In classic mode,
delegates to existing full-featured handlers.
"""

import asyncio
Expand Down Expand Up @@ -34,6 +34,7 @@
from ..config.settings import Settings
from ..projects import PrivateTopicsUnavailableError
from .utils.draft_streamer import DraftStreamer, generate_draft_id
from .utils.effort import EFFORT_LEVELS, EFFORT_STATE_KEY, get_effort
from .utils.html_format import escape_html
from .utils.image_extractor import (
ImageAttachment,
Expand Down Expand Up @@ -244,6 +245,11 @@ async def _apply_thread_routing_context(

context.user_data["current_directory"] = current_dir
context.user_data["claude_session_id"] = state.get("claude_session_id")
effort = state.get(EFFORT_STATE_KEY)
if effort in EFFORT_LEVELS:
context.user_data[EFFORT_STATE_KEY] = effort
else:
context.user_data.pop(EFFORT_STATE_KEY, None)
context.user_data["_thread_context"] = {
"chat_id": chat.id,
"message_thread_id": message_thread_id,
Expand Down Expand Up @@ -272,6 +278,7 @@ def _persist_thread_state(self, context: ContextTypes.DEFAULT_TYPE) -> None:
thread_states[thread_context["state_key"]] = {
"current_directory": str(current_dir),
"claude_session_id": context.user_data.get("claude_session_id"),
EFFORT_STATE_KEY: get_effort(context),
"project_slug": thread_context["project_slug"],
}

Expand Down Expand Up @@ -336,6 +343,7 @@ def _register_agentic_handlers(self, app: Application) -> None:
("new", self.agentic_new),
("status", self.agentic_status),
("verbose", self.agentic_verbose),
("effort", command.effort_command),
("repo", self.agentic_repo),
("restart", command.restart_command),
]
Expand Down Expand Up @@ -431,6 +439,7 @@ def _register_classic_handlers(self, app: Application) -> None:
("pwd", command.print_working_directory),
("projects", command.show_projects),
("status", command.session_status),
("effort", command.effort_command),
("export", command.export_session),
("actions", command.quick_actions),
("git", command.git_command),
Expand Down Expand Up @@ -467,7 +476,7 @@ def _register_classic_handlers(self, app: Application) -> None:
CallbackQueryHandler(self._inject_deps(callback.handle_callback_query))
)

logger.info("Classic handlers registered (13 commands + full handler set)")
logger.info("Classic handlers registered (15 commands + full handler set)")

async def get_bot_commands(self) -> list: # type: ignore[type-arg]
"""Return bot commands appropriate for current mode."""
Expand All @@ -477,6 +486,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("new", "Start a fresh session"),
BotCommand("status", "Show session status"),
BotCommand("verbose", "Set output verbosity (0/1/2)"),
BotCommand("effort", "Set Claude reasoning effort"),
BotCommand("repo", "List repos / switch workspace"),
BotCommand("restart", "Restart the bot"),
]
Expand All @@ -495,6 +505,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("pwd", "Show current directory"),
BotCommand("projects", "Show all projects"),
BotCommand("status", "Show session status"),
BotCommand("effort", "Set Claude reasoning effort"),
BotCommand("export", "Export current session"),
BotCommand("actions", "Show quick actions"),
BotCommand("git", "Git repository commands"),
Expand Down Expand Up @@ -555,7 +566,7 @@ async def agentic_start(
f"Hi {safe_name}! I'm your AI coding assistant.\n"
f"Just tell me what you need — I can read, write, and run code.\n\n"
f"Working in: {dir_display}\n"
f"Commands: /new (reset) · /status"
f"Commands: /new (reset) · /status · /effort"
f"{sync_line}",
parse_mode="HTML",
)
Expand All @@ -581,6 +592,7 @@ async def agentic_status(

session_id = context.user_data.get("claude_session_id")
session_status = "active" if session_id else "none"
effort = get_effort(context) or "default"

# Cost info
cost_str = ""
Expand All @@ -595,7 +607,7 @@ async def agentic_status(
pass

await update.message.reply_text(
f"📂 {dir_display} · Session: {session_status}{cost_str}"
f"📂 {dir_display} · Session: {session_status} · Effort: {effort}{cost_str}"
)

def _get_verbose_level(self, context: ContextTypes.DEFAULT_TYPE) -> int:
Expand Down Expand Up @@ -1083,6 +1095,7 @@ async def agentic_text(
force_new=force_new,
interrupt_event=interrupt_event,
approval_callback=approval_cb,
effort=get_effort(context),
)

# New session created successfully — clear the one-shot flag
Expand Down Expand Up @@ -1333,6 +1346,7 @@ async def agentic_document(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
effort=get_effort(context),
)

if force_new:
Expand Down Expand Up @@ -1543,6 +1557,7 @@ async def _handle_agentic_media_message(
on_stream=on_stream,
force_new=force_new,
images=images,
effort=get_effort(context),
)
finally:
heartbeat.cancel()
Expand Down
Loading
Loading