Address 3 blockers and 1 warning: - Clarify on_tool_use responsibility split (subprocess passes raw data, bot.py formats) - Make verify step 6 concrete with code inspection pattern - Add explicit subprocess auto-start pseudo-code with double-start guard - Reframe must_haves truth from implementation detail to capability Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
231 lines
12 KiB
Markdown
231 lines
12 KiB
Markdown
---
|
|
phase: 02-telegram-integration
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- telegram/claude_subprocess.py
|
|
- telegram/telegram_utils.py
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Persistent Claude Code subprocess accepts multiple messages without respawning"
|
|
- "Subprocess emits tool_use events (tool_name + tool_input dict) that bot.py can consume for progress notifications"
|
|
- "Long messages are split at smart boundaries without breaking MarkdownV2 code blocks"
|
|
- "MarkdownV2 special characters are properly escaped before sending to Telegram"
|
|
- "Typing indicator can be maintained for arbitrarily long operations via re-send loop"
|
|
artifacts:
|
|
- path: "telegram/claude_subprocess.py"
|
|
provides: "Persistent subprocess with stream-json stdin/stdout"
|
|
contains: "input-format.*stream-json"
|
|
- path: "telegram/telegram_utils.py"
|
|
provides: "Message splitting, MarkdownV2 escaping, typing indicator loop"
|
|
exports: ["split_message_smart", "escape_markdown_v2", "typing_indicator_loop"]
|
|
key_links:
|
|
- from: "telegram/claude_subprocess.py"
|
|
to: "claude CLI"
|
|
via: "--input-format stream-json stdin piping"
|
|
pattern: "input-format.*stream-json"
|
|
- from: "telegram/claude_subprocess.py"
|
|
to: "callbacks"
|
|
via: "on_output/on_error/on_complete/on_status/on_tool_use"
|
|
pattern: "on_tool_use"
|
|
---
|
|
|
|
<objective>
|
|
Refactor ClaudeSubprocess from fresh-process-per-turn to persistent process with stream-json I/O, and create Telegram utility functions for message formatting and typing indicators.
|
|
|
|
Purpose: The persistent process model eliminates ~1s spawn overhead per message, maintains conversation context across turns, and enables real-time tool call notifications. The utility functions provide safe MarkdownV2 formatting and smart message splitting that the bot integration (Plan 02) will consume.
|
|
|
|
Output: Refactored `claude_subprocess.py` with persistent subprocess, new `telegram_utils.py` with message splitting/escaping/typing utilities.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/02-telegram-integration/02-RESEARCH.md
|
|
@.planning/phases/02-telegram-integration/02-CONTEXT.md
|
|
@.planning/phases/01-session-process-foundation/01-02-SUMMARY.md
|
|
@telegram/claude_subprocess.py
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Refactor ClaudeSubprocess to persistent process with stream-json I/O</name>
|
|
<files>telegram/claude_subprocess.py</files>
|
|
<action>
|
|
Refactor ClaudeSubprocess to maintain a single long-lived process per session using `--input-format stream-json --output-format stream-json` instead of spawning fresh `claude -p` per turn.
|
|
|
|
**Key changes:**
|
|
|
|
1. **New start() method:** Spawns persistent subprocess with:
|
|
```
|
|
claude -p --input-format stream-json --output-format stream-json --verbose
|
|
```
|
|
Plus persona flags (--system-prompt, --model, --max-turns) and --continue if `.claude/` exists.
|
|
Launches background tasks for `_read_stdout()` and `_read_stderr()` (concurrent readers from Phase 1 pattern).
|
|
|
|
2. **Refactor send_message():** Instead of spawning a new process, writes NDJSON to stdin:
|
|
```python
|
|
msg = json.dumps({"type": "user", "content": message}) + '\n'
|
|
self._process.stdin.write(msg.encode())
|
|
await self._process.stdin.drain() # CRITICAL: prevent buffer deadlock
|
|
```
|
|
Sets `_busy = True`. If process not started, calls `start()` first.
|
|
If already busy, queues the message (existing queue behavior preserved).
|
|
|
|
3. **Add on_tool_use callback:** New callback `on_tool_use(tool_name: str, tool_input: dict)` for progress notifications.
|
|
In `_handle_stdout_line()`, parse `content_block_start` events with `type: "tool_use"` to extract tool name and input.
|
|
Stream-json emits these as separate events during assistant turns.
|
|
**Responsibility split:** `claude_subprocess.py` extracts `tool_name` (e.g. "Read", "Bash", "Edit") and passes the raw `tool_input` dict (e.g. `{"file_path": "/foo/bar"}`, `{"command": "ls -la"}`) directly to the callback. It does NOT format human-readable descriptions -- that is bot.py's job in Plan 02 (Part B, item 5). This keeps the subprocess layer format-agnostic.
|
|
|
|
4. **Update _handle_stdout_line():** Handle the stream-json event types from persistent mode:
|
|
- `assistant`: Extract text blocks, call on_output with final text
|
|
- `result`: Turn complete, set `_busy = False`, call on_complete, process queue
|
|
- `content_block_start` / `content_block_delta` with tool_use type: Extract tool name + target, call on_tool_use
|
|
- `system`: System events, log and handle errors
|
|
|
|
5. **Update _read_streams() / _read_stdout():** Since the process is persistent, the stdout reader must NOT exit when a turn completes. It stays alive reading events indefinitely. Remove the "mark as not busy" logic from _read_streams and move it to the result event handler in _handle_stdout_line instead. The reader only exits when process dies (readline returns empty bytes).
|
|
|
|
6. **Process lifecycle:**
|
|
- `start()` — spawns process, starts readers
|
|
- `send_message()` — writes to stdin (auto-starts if needed)
|
|
- `terminate()` — closes stdin, sends SIGTERM, waits, SIGKILL fallback (existing pattern)
|
|
- `is_alive` — check process.returncode is None
|
|
- `is_busy` — check _busy flag
|
|
|
|
7. **Crash recovery:** Keep existing crash recovery logic but adapt: when process dies unexpectedly, restart with `start()` and resend any queued messages. The --continue flag in start() ensures session context is preserved.
|
|
|
|
8. **Remove _spawn() method:** Replace with start() for process lifecycle and send_message() for message delivery. The old pattern of passing message to _spawn() is no longer needed since messages go to stdin.
|
|
|
|
**Preserve from Phase 1:**
|
|
- Callback architecture (on_output, on_error, on_complete, on_status + new on_tool_use)
|
|
- Message queue (asyncio.Queue)
|
|
- Graceful termination (SIGTERM → wait_for → SIGKILL → wait)
|
|
- Crash retry logic (MAX_CRASH_RETRIES, backoff)
|
|
- PATH environment setup
|
|
- Session directory as cwd
|
|
- Timing/logging instrumentation
|
|
|
|
**Important implementation notes:**
|
|
- The stream-json input format message structure: Test with `{"type": "user", "content": "message text"}`. If that fails, try simpler `{"content": "message text"}`. Check `claude --help` or test interactively.
|
|
- Always `await proc.stdin.drain()` after writing to prevent pipe buffer deadlock
|
|
- The stdout reader task must run for the lifetime of the process, not per-turn
|
|
- Track busy state via result events, not process completion
|
|
</action>
|
|
<verify>
|
|
1. Run `python -c "import telegram.claude_subprocess; print('import OK')"` from ~/homelab (or equivalent sibling import test)
|
|
2. Verify the class has start(), send_message(), terminate() methods
|
|
3. Verify on_tool_use callback parameter exists in __init__
|
|
4. Verify --input-format stream-json appears in the command construction
|
|
5. Verify stdin.write + drain pattern in send_message
|
|
6. Verify `_read_stdout()` loop continues after result events: inspect code to confirm the loop only exits on empty readline (i.e. `line = await self._process.stdout.readline(); if not line: break`), NOT on receiving a result event. Result events should set `_busy = False` and call `on_complete` but NOT break the reader loop.
|
|
</verify>
|
|
<done>
|
|
ClaudeSubprocess maintains a persistent process that accepts NDJSON messages on stdin, emits stream-json events on stdout, routes tool_use events to on_tool_use callback, and tracks busy state via result events instead of process completion. No fresh process spawned per turn.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create telegram_utils.py with message formatting and typing indicator</name>
|
|
<files>telegram/telegram_utils.py</files>
|
|
<action>
|
|
Create `telegram/telegram_utils.py` with three utility functions consumed by the bot integration in Plan 02.
|
|
|
|
**1. Smart message splitting — `split_message_smart(text: str, max_length: int = 4000) -> list[str]`:**
|
|
- Split long messages respecting MarkdownV2 code block boundaries
|
|
- Never split inside triple-backtick code blocks
|
|
- Prefer paragraph breaks (`\n\n`), then line breaks (`\n`), then hard character split as last resort
|
|
- Use 4000 as default max (not 4096) to leave room for MarkdownV2 escape character expansion
|
|
- Track `in_code_block` state by counting triple-backtick lines
|
|
- If a code block exceeds max_length by itself, include it whole (Telegram will handle overflow gracefully or we truncate with "... (truncated)" marker)
|
|
- Algorithm from research: iterate lines, track code block state, split only when NOT in code block and would exceed limit
|
|
|
|
**2. MarkdownV2 escaping — `escape_markdown_v2(text: str) -> str`:**
|
|
- Escape the 17 MarkdownV2 special characters: `_ * [ ] ( ) ~ \` > # + - = | { } . !`
|
|
- BUT do NOT escape inside code blocks (text between triple backticks or single backticks)
|
|
- Strategy: Split text by code regions, escape only non-code regions, rejoin
|
|
- For inline code (single backtick): don't escape content between backticks
|
|
- For code blocks (triple backtick): don't escape content between triple backticks
|
|
- Use regex to identify code regions: find ```...``` and `...` blocks, escape everything else
|
|
|
|
**3. Typing indicator loop — `async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event)`:**
|
|
- Send `ChatAction.TYPING` every 4 seconds until stop_event is set
|
|
- Use `asyncio.wait_for(stop_event.wait(), timeout=4.0)` pattern from research
|
|
- Catch exceptions from send_chat_action (network errors) and log warning, continue loop
|
|
- Clean exit when stop_event is set
|
|
- Import from `telegram import ChatAction`
|
|
|
|
**Module structure:**
|
|
```python
|
|
"""
|
|
Telegram message formatting and UX utilities.
|
|
|
|
Provides smart message splitting, MarkdownV2 escaping, and typing indicator
|
|
management for the Telegram Claude Code bridge.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from telegram import ChatAction
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TELEGRAM_MAX_LENGTH = 4096
|
|
SAFE_LENGTH = 4000
|
|
|
|
def split_message_smart(text: str, max_length: int = SAFE_LENGTH) -> list[str]:
|
|
...
|
|
|
|
def escape_markdown_v2(text: str) -> str:
|
|
...
|
|
|
|
async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event):
|
|
...
|
|
```
|
|
</action>
|
|
<verify>
|
|
1. Run `python -c "from telegram_utils import split_message_smart, escape_markdown_v2, typing_indicator_loop; print('imports OK')"` from ~/homelab/telegram/
|
|
2. Test split_message_smart: `split_message_smart("a" * 5000)` returns list with 2+ chunks, each <= 4000 chars
|
|
3. Test split_message_smart with code block: message containing ``` block is not split inside the block
|
|
4. Test escape_markdown_v2: `escape_markdown_v2("hello_world")` returns `"hello\_world"`
|
|
5. Test escape_markdown_v2 preserves code: text inside backticks is NOT escaped
|
|
</verify>
|
|
<done>
|
|
telegram_utils.py exists with split_message_smart (code-block-aware splitting), escape_markdown_v2 (context-sensitive escaping), and typing_indicator_loop (4s re-send with asyncio.Event cancellation). All functions importable and tested with basic cases.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. `python -c "from telegram.claude_subprocess import ClaudeSubprocess"` succeeds (or sibling import equivalent)
|
|
2. `python -c "from telegram_utils import split_message_smart, escape_markdown_v2, typing_indicator_loop"` from telegram/ succeeds
|
|
3. ClaudeSubprocess.__init__ accepts on_tool_use callback
|
|
4. ClaudeSubprocess has start(), send_message(), terminate() methods
|
|
5. send_message() writes NDJSON to stdin (not spawning new process)
|
|
6. split_message_smart handles code blocks without breaking them
|
|
7. escape_markdown_v2 escapes outside code blocks only
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- ClaudeSubprocess uses persistent process with --input-format stream-json
|
|
- Messages sent via stdin NDJSON, not fresh process spawn
|
|
- Tool use events parsed and routed to on_tool_use callback
|
|
- Smart message splitting respects code block boundaries
|
|
- MarkdownV2 escaping handles all 17 special characters with code block awareness
|
|
- Typing indicator loop re-sends every 4 seconds with clean cancellation
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-telegram-integration/02-01-SUMMARY.md`
|
|
</output>
|