Skip to content

Commit bcb5ef3

Browse files
committed
feat(tasklist): per-task model/effort overrides + docker-compose + entrypoint
Task model support ------------------ - TaskItem gains optional `model` and `effort` fields (e.g. "opus", "high") - DB migration 6: ALTER TABLE adds `model TEXT` and `effort TEXT` columns - TaskListRepository.add_task() accepts model/effort and persists them - /tasks add and /tasks plan parse --model and --effort flags anywhere in the argument string (case-insensitive, validated against known values) - /tasks list shows a [Opus/high] badge next to tasks that have an override - /tasks plan applies the flags as a batch default to every generated task Runner model resolution (priority order) ----------------------------------------- 1. Per-task model/effort stored in the DB 2. Runner-wide default — inherited from the user's active /model setting at the time /taskrun was issued (context.user_data model_override / effort_override, set by PR RichardAtCT#160's /model command) 3. API/CLI default (no override passed) /taskrun confirmation message now names the active default model when set. The sanity-check call uses the same model as the task itself. Docker ------ - Add entrypoint.sh: generates an SSH keypair on first run and prints the public key, then execs claude-telegram-bot - Add docker-compose.yml: builds from local source (pyproject.toml pip install), mounts Claude CLI binary from host, persists SQLite data and SSH key in named volumes across rebuilds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent a8eb515 commit bcb5ef3

7 files changed

Lines changed: 273 additions & 43 deletions

File tree

docker-compose.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# docker-compose.yml — claude-code-telegram-tasklist on Raspberry Pi
2+
# Builds directly from this repo — no external image pull needed.
3+
#
4+
# Setup on your Pi:
5+
# 1. Install & auth Claude CLI: claude (follow the prompts)
6+
# 2. Copy .env.template → .env and fill in your values
7+
# 3. docker compose up -d --build
8+
9+
services:
10+
claude-tg-bot:
11+
build:
12+
context: .
13+
dockerfile_inline: |
14+
FROM python:3.11-slim
15+
16+
RUN apt-get update && apt-get install -y --no-install-recommends \
17+
git curl ca-certificates sqlite3 openssh-client \
18+
&& rm -rf /var/lib/apt/lists/*
19+
20+
WORKDIR /app
21+
22+
# Install the bot from the local source tree (pyproject.toml)
23+
COPY pyproject.toml poetry.lock* ./
24+
RUN pip install --no-cache-dir --upgrade pip \
25+
&& pip install --no-cache-dir .
26+
27+
# Copy the rest of the source (keeps the pip layer cached on dep-only changes)
28+
COPY src ./src
29+
30+
# Claude auth credentials baked into the image at build time.
31+
# Delete this COPY and uncomment the runtime volume below if you prefer
32+
# to mount credentials without baking them into an image layer.
33+
COPY .claude /root/.claude
34+
RUN mkdir -p /root/.ssh /root/.claude
35+
36+
COPY entrypoint.sh /entrypoint.sh
37+
RUN chmod +x /entrypoint.sh
38+
39+
ENTRYPOINT ["/entrypoint.sh"]
40+
41+
image: claude-tg-bot
42+
restart: unless-stopped
43+
44+
environment:
45+
- TZ=Europe/Warsaw # adjust to your timezone
46+
47+
env_file:
48+
- .env # copy .env.template → .env and fill in your values
49+
50+
volumes:
51+
# Claude CLI binary from the host Pi.
52+
# Run `which claude` on the Pi if it lives somewhere else.
53+
- /home/k4rnaj1k/.local/bin/claude:/usr/local/bin/claude:ro
54+
55+
# Uncomment to mount Claude credentials at runtime instead of baking them in:
56+
# - /home/k4rnaj1k/.claude:/root/.claude:ro
57+
58+
# Your projects directory — must match APPROVED_DIRECTORY in .env
59+
- ./projects:/projects
60+
61+
# Persistent SQLite database and SSH key across container rebuilds
62+
- claude_bot_data:/app/data
63+
- claude_bot_ssh:/root/.ssh
64+
65+
volumes:
66+
claude_bot_data:
67+
claude_bot_ssh:

entrypoint.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/sh
2+
set -e
3+
4+
# Generate SSH key if it doesn't exist yet
5+
if [ ! -f /root/.ssh/id_ed25519 ]; then
6+
ssh-keygen -t ed25519 -C "claude-tg-bot@container" -f /root/.ssh/id_ed25519 -N ""
7+
fi
8+
9+
echo ""
10+
echo "════════════════════════════════════════════════════════"
11+
echo " SSH PUBLIC KEY — add this to your GitHub/Gitea/etc:"
12+
echo "════════════════════════════════════════════════════════"
13+
cat /root/.ssh/id_ed25519.pub
14+
echo "════════════════════════════════════════════════════════"
15+
echo ""
16+
17+
exec /usr/local/bin/claude-telegram-bot

src/bot/orchestrator.py

Lines changed: 113 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,21 +1786,50 @@ async def _agentic_callback(
17861786
# Task list commands
17871787
# ------------------------------------------------------------------
17881788

1789+
@staticmethod
1790+
def _parse_task_flags(text: str) -> tuple:
1791+
"""Strip --model and --effort flags from *text*, return (model, effort, clean_text).
1792+
1793+
Accepted forms (case-insensitive, anywhere in the string):
1794+
--model opus --model sonnet --model haiku
1795+
--effort low --effort medium --effort high --effort max
1796+
"""
1797+
import re as _re
1798+
1799+
_VALID_MODELS = {"opus", "sonnet", "haiku"}
1800+
_VALID_EFFORTS = {"low", "medium", "high", "max"}
1801+
1802+
model: Optional[str] = None
1803+
effort: Optional[str] = None
1804+
1805+
def _extract(pattern: str, valid: set, src: str) -> tuple:
1806+
m = _re.search(pattern, src, _re.IGNORECASE)
1807+
if m:
1808+
val = m.group(1).lower()
1809+
if val in valid:
1810+
return val, src[: m.start()].rstrip() + " " + src[m.end() :].lstrip()
1811+
return None, src
1812+
1813+
model, text = _extract(r"--model\s+(\S+)", _VALID_MODELS, text)
1814+
effort, text = _extract(r"--effort\s+(\S+)", _VALID_EFFORTS, text)
1815+
return model, effort, text.strip()
1816+
17891817
async def agentic_tasks(
17901818
self, update: Update, context: ContextTypes.DEFAULT_TYPE
17911819
) -> None:
17921820
"""Manage the task list.
17931821
17941822
Usage:
1795-
/tasks — show list
1796-
/tasks add <description> — add a task manually
1797-
/tasks plan <description> — ask Claude to decompose into tasks
1798-
/tasks del <id> — delete a task by id
1799-
/tasks clear — clear all tasks
1800-
/tasks reset — reset failed/in-progress tasks to pending
1823+
/tasks — show list
1824+
/tasks add [--model M] [--effort E] <desc> — add a task
1825+
/tasks plan [--model M] [--effort E] <desc> — Claude decomposes into tasks
1826+
/tasks del <id> — delete a task by id
1827+
/tasks clear — clear all tasks
1828+
/tasks reset — reset failed/in-progress to pending
1829+
1830+
Model values : opus · sonnet · haiku
1831+
Effort values: low · medium · high · max (model-dependent)
18011832
"""
1802-
from ..tasklist.runner import TaskRunner # local import, already cached
1803-
18041833
user_id = update.effective_user.id
18051834
storage = context.bot_data.get("storage")
18061835
if not storage:
@@ -1814,7 +1843,7 @@ async def agentic_tasks(
18141843
rest = parts[2].strip() if len(parts) > 2 else ""
18151844

18161845
# --- show list ---
1817-
if sub == "" or sub == "list":
1846+
if sub in ("", "list"):
18181847
tasks = await task_repo.get_tasks(user_id)
18191848
if not tasks:
18201849
await update.message.reply_text(
@@ -1827,14 +1856,23 @@ async def agentic_tasks(
18271856

18281857
counts = await task_repo.count_by_status(user_id)
18291858
running = self._task_runners.get(user_id)
1830-
runner_badge = " · <b>runner active</b>" if (
1831-
running and running.is_running()
1832-
) else ""
1859+
runner_badge = (
1860+
" · <b>runner active</b>"
1861+
if running and running.is_running()
1862+
else ""
1863+
)
18331864

18341865
lines = [f"📋 <b>Task list</b>{runner_badge}\n"]
18351866
for t in tasks:
18361867
desc = escape_html(t.description[:80])
1837-
lines.append(f"{t.icon} <code>{t.id}</code> {desc}")
1868+
model_badge = (
1869+
f" <code>[{t.model.capitalize()}"
1870+
+ (f"/{t.effort}" if t.effort else "")
1871+
+ "]</code>"
1872+
if t.model
1873+
else ""
1874+
)
1875+
lines.append(f"{t.icon} <code>{t.id}</code>{model_badge} {desc}")
18381876

18391877
summary_parts = []
18401878
for status, label in (
@@ -1848,7 +1886,7 @@ async def agentic_tasks(
18481886
summary_parts.append(f"{n} {label}")
18491887
lines.append("\n" + " · ".join(summary_parts))
18501888
lines.append(
1851-
"\n<i>/taskrun to start · /tasks add &lt;desc&gt; to add more</i>"
1889+
"\n<i>/taskrun to start · /tasks add [--model opus] &lt;desc&gt;</i>"
18521890
)
18531891

18541892
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
@@ -1858,13 +1896,26 @@ async def agentic_tasks(
18581896
if sub == "add":
18591897
if not rest:
18601898
await update.message.reply_text(
1861-
"Usage: <code>/tasks add &lt;description&gt;</code>",
1899+
"Usage: <code>/tasks add [--model opus|sonnet|haiku]"
1900+
" [--effort low|medium|high|max] &lt;description&gt;</code>",
18621901
parse_mode="HTML",
18631902
)
18641903
return
1865-
task = await task_repo.add_task(user_id, rest)
1904+
model, effort, desc = self._parse_task_flags(rest)
1905+
if not desc:
1906+
await update.message.reply_text("Please include a task description.")
1907+
return
1908+
task = await task_repo.add_task(user_id, desc, model=model, effort=effort)
1909+
badge = (
1910+
f" <code>[{model.capitalize()}"
1911+
+ (f"/{effort}" if effort else "")
1912+
+ "]</code>"
1913+
if model
1914+
else ""
1915+
)
18661916
await update.message.reply_text(
1867-
f"✅ Task <code>{task.id}</code> added: <i>{escape_html(rest[:80])}</i>",
1917+
f"✅ Task <code>{task.id}</code> added{badge}:"
1918+
f" <i>{escape_html(desc[:80])}</i>",
18681919
parse_mode="HTML",
18691920
)
18701921
return
@@ -1873,8 +1924,8 @@ async def agentic_tasks(
18731924
if sub == "plan":
18741925
if not rest:
18751926
await update.message.reply_text(
1876-
"Usage: <code>/tasks plan &lt;description&gt;</code>\n"
1877-
"Example: <code>/tasks plan refactor auth, add dark mode, write tests</code>",
1927+
"Usage: <code>/tasks plan [--model M] [--effort E] &lt;description&gt;</code>\n"
1928+
"Example: <code>/tasks plan --model opus refactor auth, add dark mode</code>",
18781929
parse_mode="HTML",
18791930
)
18801931
return
@@ -1884,14 +1935,20 @@ async def agentic_tasks(
18841935
await update.message.reply_text("Claude integration unavailable.")
18851936
return
18861937

1938+
# Flags here apply to every created task (acts as a batch default).
1939+
model, effort, description = self._parse_task_flags(rest)
1940+
if not description:
1941+
await update.message.reply_text("Please include a description to plan.")
1942+
return
1943+
18871944
thinking_msg = await update.message.reply_text("🧠 Planning tasks…")
18881945

18891946
decompose_prompt = (
18901947
"Break the following work description into a concise, ordered task list.\n"
18911948
"Output ONLY the tasks — one per line, no numbering, no bullet points, "
18921949
"no extra commentary.\n"
18931950
"Each line must be a single, self-contained actionable task.\n\n"
1894-
f"Work description: {rest}"
1951+
f"Work description: {description}"
18951952
)
18961953

18971954
current_dir = context.user_data.get(
@@ -1930,13 +1987,20 @@ async def agentic_tasks(
19301987
return
19311988

19321989
created = []
1933-
for desc in task_lines:
1934-
t = await task_repo.add_task(user_id, desc)
1990+
for task_desc in task_lines:
1991+
t = await task_repo.add_task(
1992+
user_id, task_desc, model=model, effort=effort
1993+
)
19351994
created.append(t)
19361995

1937-
lines = [
1938-
f"📋 <b>{len(created)} task(s) created:</b>\n"
1939-
]
1996+
model_note = (
1997+
f" (all using <code>{model.capitalize()}"
1998+
+ (f"/{effort}" if effort else "")
1999+
+ "</code>)"
2000+
if model
2001+
else ""
2002+
)
2003+
lines = [f"📋 <b>{len(created)} task(s) created{model_note}:</b>\n"]
19402004
for t in created:
19412005
lines.append(
19422006
f"{t.icon} <code>{t.id}</code> <i>{escape_html(t.description[:80])}</i>"
@@ -1967,15 +2031,12 @@ async def agentic_tasks(
19672031
# --- clear ---
19682032
if sub == "clear":
19692033
count = await task_repo.clear_tasks(user_id)
1970-
await update.message.reply_text(
1971-
f"🗑️ Cleared {count} task(s)."
1972-
)
2034+
await update.message.reply_text(f"🗑️ Cleared {count} task(s).")
19732035
return
19742036

19752037
# --- reset ---
19762038
if sub == "reset":
19772039
count = await task_repo.reset_stale_in_progress(user_id)
1978-
# Also reset explicitly failed tasks to pending
19792040
tasks = await task_repo.get_tasks(user_id)
19802041
reset_failed = 0
19812042
for t in tasks:
@@ -1994,13 +2055,16 @@ async def agentic_tasks(
19942055
await update.message.reply_text(
19952056
"<b>Task list commands:</b>\n"
19962057
"<code>/tasks</code> — show list\n"
1997-
"<code>/tasks add &lt;description&gt;</code> — add a task\n"
1998-
"<code>/tasks plan &lt;description&gt;</code> — Claude plans tasks for you\n"
2058+
"<code>/tasks add [--model M] [--effort E] &lt;desc&gt;</code> — add a task\n"
2059+
"<code>/tasks plan [--model M] [--effort E] &lt;desc&gt;</code>"
2060+
" — Claude plans tasks for you\n"
19992061
"<code>/tasks del &lt;id&gt;</code> — delete a task\n"
20002062
"<code>/tasks clear</code> — clear all tasks\n"
20012063
"<code>/tasks reset</code> — reset failed tasks to pending\n"
20022064
"<code>/taskrun</code> — start the runner\n"
2003-
"<code>/taskstop</code> — stop the runner",
2065+
"<code>/taskstop</code> — stop the runner\n\n"
2066+
"<i>Model: opus · sonnet · haiku"
2067+
" | Effort: low · medium · high · max</i>",
20042068
parse_mode="HTML",
20052069
)
20062070

@@ -2041,6 +2105,11 @@ async def agentic_taskrun(
20412105
)
20422106
session_id = context.user_data.get("claude_session_id")
20432107

2108+
# Inherit the user's active /model selection as the runner-wide default.
2109+
# Individual tasks can still override this via their own model field.
2110+
default_model = context.user_data.get("model_override")
2111+
default_effort = context.user_data.get("effort_override")
2112+
20442113
runner = TaskRunner(
20452114
user_id=user_id,
20462115
chat_id=update.effective_chat.id,
@@ -2049,12 +2118,23 @@ async def agentic_taskrun(
20492118
claude_integration=claude_integration,
20502119
working_directory=current_dir,
20512120
session_id=session_id,
2121+
default_model=default_model,
2122+
default_effort=default_effort,
20522123
)
20532124
self._task_runners[user_id] = runner
20542125
runner.start()
20552126

2127+
model_note = (
2128+
f" using <b>{default_model.capitalize()}"
2129+
+ (f"/{default_effort}" if default_effort else "")
2130+
+ "</b> as default"
2131+
if default_model
2132+
else ""
2133+
)
20562134
await update.message.reply_text(
2057-
"▶️ Task runner started. I'll work through your list and report back.",
2135+
f"▶️ Task runner started{model_note}."
2136+
" I'll work through your list and report back.",
2137+
parse_mode="HTML",
20582138
)
20592139

20602140
audit_logger = context.bot_data.get("audit_logger")

src/storage/database.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,14 @@ def _get_migrations(self) -> List[Tuple[int, str]]:
309309
ON task_list_items(user_id, position);
310310
""",
311311
),
312+
(
313+
6,
314+
"""
315+
-- Add per-task model and effort overrides
316+
ALTER TABLE task_list_items ADD COLUMN model TEXT;
317+
ALTER TABLE task_list_items ADD COLUMN effort TEXT;
318+
""",
319+
),
312320
(
313321
4,
314322
"""

src/tasklist/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,24 @@ class TaskItem:
4242
created_at: Optional[datetime] = None
4343
completed_at: Optional[datetime] = None
4444
notes: Optional[str] = None # last sanity-check result
45+
model: Optional[str] = None # e.g. "opus", "sonnet", "haiku"
46+
effort: Optional[str] = None # e.g. "low", "medium", "high", "max"
4547

4648
@property
4749
def icon(self) -> str:
4850
"""Status icon for display."""
4951
return STATUS_ICONS.get(self.status, "❓")
5052

53+
@property
54+
def model_badge(self) -> str:
55+
"""Short display string for model+effort, empty if unset."""
56+
if not self.model:
57+
return ""
58+
badge = self.model.capitalize()
59+
if self.effort:
60+
badge += f"/{self.effort}"
61+
return f" [{badge}]"
62+
5163
def to_dict(self) -> Dict[str, Any]:
5264
"""Serialise for storage / logging."""
5365
data = asdict(self)

0 commit comments

Comments
 (0)