fix(02-02): typing indicator lifecycle, model identity, tool permissions

- Fix typing indicator not showing: clean up stale typing tasks between
  messages and use dynamic event lookup in callbacks instead of capturing
  a specific event at creation time
- Fix stream-json input format: use nested message object for stdin NDJSON
- Switch --system-prompt to --append-system-prompt so Claude Code's
  default system prompt (with model identity) is preserved
- Add --dangerously-skip-permissions for full tool access in subprocess
- Use full model ID (claude-sonnet-4-5-20250929) in default persona

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-02-04 22:10:02 +00:00
parent f246d18fa0
commit 2d0d4da992
4 changed files with 57 additions and 37 deletions

View file

@ -68,8 +68,18 @@ def is_authorized(user_id: int) -> bool:
"""Check if user is authorized.""" """Check if user is authorized."""
return user_id in get_authorized_users() return user_id in get_authorized_users()
def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event): def make_callbacks(bot, chat_id, session_name: str):
"""Create callbacks for ClaudeSubprocess bound to specific chat with typing control.""" """Create callbacks for ClaudeSubprocess bound to specific chat with dynamic typing control.
Typing events are looked up dynamically from typing_tasks dict so callbacks
always reference the CURRENT typing indicator, not a stale one from creation time.
"""
def _stop_typing():
"""Stop the current typing indicator for this session (if any)."""
if session_name in typing_tasks:
_, stop_event = typing_tasks[session_name]
stop_event.set()
async def on_output(text): async def on_output(text):
t0 = time.monotonic() t0 = time.monotonic()
@ -86,8 +96,8 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
logger.warning(f"MarkdownV2 parse failed, falling back to plain text: {e}") logger.warning(f"MarkdownV2 parse failed, falling back to plain text: {e}")
await bot.send_message(chat_id=chat_id, text=chunk) await bot.send_message(chat_id=chat_id, text=chunk)
# Stop typing indicator # Stop typing indicator (dynamic lookup)
stop_typing_event.set() _stop_typing()
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
logger.info(f"[TIMING] Telegram send: {elapsed:.3f}s ({len(text)} chars, {len(chunks)} chunks)") logger.info(f"[TIMING] Telegram send: {elapsed:.3f}s ({len(text)} chars, {len(chunks)} chunks)")
@ -95,11 +105,11 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
async def on_error(error): async def on_error(error):
await bot.send_message(chat_id=chat_id, text=f"Error: {error}") await bot.send_message(chat_id=chat_id, text=f"Error: {error}")
# Stop typing indicator on error # Stop typing indicator on error
stop_typing_event.set() _stop_typing()
async def on_complete(): async def on_complete():
# Stop typing indicator on completion # Stop typing indicator on completion
stop_typing_event.set() _stop_typing()
async def on_status(status): async def on_status(status):
await bot.send_message(chat_id=chat_id, text=f"[{status}]") await bot.send_message(chat_id=chat_id, text=f"[{status}]")
@ -372,11 +382,8 @@ async def new_session(update: Update, context: ContextTypes.DEFAULT_TYPE):
persona_config = session_manager.get_session(name) persona_config = session_manager.get_session(name)
persona_data = session_manager.load_persona(persona or 'default') persona_data = session_manager.load_persona(persona or 'default')
# Create stop typing event for this session # Create callbacks bound to this chat (typing looked up dynamically)
stop_typing = asyncio.Event() callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
# Create callbacks bound to this chat
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
# Create subprocess # Create subprocess
subprocesses[name] = ClaudeSubprocess( subprocesses[name] = ClaudeSubprocess(
@ -459,11 +466,8 @@ async def switch_session_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE)
persona_name = session_data.get('persona', 'default') persona_name = session_data.get('persona', 'default')
persona_data = session_manager.load_persona(persona_name) persona_data = session_manager.load_persona(persona_name)
# Create stop typing event for this session # Create callbacks bound to this chat (typing looked up dynamically)
stop_typing = asyncio.Event() callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
# Create callbacks bound to this chat
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
# Create subprocess # Create subprocess
subprocesses[name] = ClaudeSubprocess( subprocesses[name] = ClaudeSubprocess(
@ -560,16 +564,19 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.info(f"[TIMING] Message received: session='{active_session}', age={msg_age:.1f}s, text={message[:50]}...") logger.info(f"[TIMING] Message received: session='{active_session}', age={msg_age:.1f}s, text={message[:50]}...")
try: try:
# Start typing indicator immediately (or reuse existing) # Clean up stale typing task (from previous message completion)
if active_session in typing_tasks:
old_task, old_event = typing_tasks[active_session]
if old_task.done():
del typing_tasks[active_session]
# Start fresh typing indicator for this message
if active_session not in typing_tasks: if active_session not in typing_tasks:
stop_typing = asyncio.Event() stop_typing = asyncio.Event()
typing_task = asyncio.create_task( typing_task = asyncio.create_task(
typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing) typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing)
) )
typing_tasks[active_session] = (typing_task, stop_typing) typing_tasks[active_session] = (typing_task, stop_typing)
else:
# Reuse existing typing indicator
typing_task, stop_typing = typing_tasks[active_session]
# Get or create subprocess for active session (avoid double-start) # Get or create subprocess for active session (avoid double-start)
already_alive = active_session in subprocesses and subprocesses[active_session].is_alive already_alive = active_session in subprocesses and subprocesses[active_session].is_alive
@ -579,8 +586,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
persona_name = session_data.get('persona', 'default') persona_name = session_data.get('persona', 'default')
persona_data = session_manager.load_persona(persona_name) persona_data = session_manager.load_persona(persona_name)
# Create callbacks bound to this chat with stop_typing event # Create callbacks bound to this chat (typing looked up dynamically)
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
# Create subprocess # Create subprocess
subprocess_inst = ClaudeSubprocess( subprocess_inst = ClaudeSubprocess(
@ -652,6 +659,12 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Get caption if any # Get caption if any
caption = update.message.caption or "" caption = update.message.caption or ""
# Clean up stale typing task
if active_session in typing_tasks:
old_task, old_event = typing_tasks[active_session]
if old_task.done():
del typing_tasks[active_session]
# Start typing indicator # Start typing indicator
if active_session not in typing_tasks: if active_session not in typing_tasks:
stop_typing = asyncio.Event() stop_typing = asyncio.Event()
@ -672,8 +685,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
persona_name = session_data.get('persona', 'default') persona_name = session_data.get('persona', 'default')
persona_data = session_manager.load_persona(persona_name) persona_data = session_manager.load_persona(persona_name)
stop_typing = typing_tasks[active_session][1] callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
subprocess_inst = ClaudeSubprocess( subprocess_inst = ClaudeSubprocess(
session_dir=session_dir, session_dir=session_dir,
@ -724,6 +736,12 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Get caption if any # Get caption if any
caption = update.message.caption or "" caption = update.message.caption or ""
# Clean up stale typing task
if active_session in typing_tasks:
old_task, old_event = typing_tasks[active_session]
if old_task.done():
del typing_tasks[active_session]
# Start typing indicator # Start typing indicator
if active_session not in typing_tasks: if active_session not in typing_tasks:
stop_typing = asyncio.Event() stop_typing = asyncio.Event()
@ -744,8 +762,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
persona_name = session_data.get('persona', 'default') persona_name = session_data.get('persona', 'default')
persona_data = session_manager.load_persona(persona_name) persona_data = session_manager.load_persona(persona_name)
stop_typing = typing_tasks[active_session][1] callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
subprocess_inst = ClaudeSubprocess( subprocess_inst = ClaudeSubprocess(
session_dir=session_dir, session_dir=session_dir,

View file

@ -134,6 +134,7 @@ class ClaudeSubprocess:
"--input-format", "stream-json", "--input-format", "stream-json",
"--output-format", "stream-json", "--output-format", "stream-json",
"--verbose", "--verbose",
"--dangerously-skip-permissions",
] ]
# Add --continue if prior session exists # Add --continue if prior session exists
@ -141,15 +142,15 @@ class ClaudeSubprocess:
cmd.append("--continue") cmd.append("--continue")
logger.debug("Using --continue flag (found existing .claude/ directory)") logger.debug("Using --continue flag (found existing .claude/ directory)")
# Add persona settings # Add persona settings (model FIRST, then system prompt)
if self._persona: if self._persona:
if "system_prompt" in self._persona:
cmd.extend(["--system-prompt", self._persona["system_prompt"]])
settings = self._persona.get("settings", {}) settings = self._persona.get("settings", {})
if "max_turns" in settings:
cmd.extend(["--max-turns", str(settings["max_turns"])])
if "model" in settings: if "model" in settings:
cmd.extend(["--model", settings["model"]]) cmd.extend(["--model", settings["model"]])
if "max_turns" in settings:
cmd.extend(["--max-turns", str(settings["max_turns"])])
if "system_prompt" in self._persona:
cmd.extend(["--append-system-prompt", self._persona["system_prompt"]])
# Prepare environment # Prepare environment
env = os.environ.copy() env = os.environ.copy()
@ -166,11 +167,10 @@ class ClaudeSubprocess:
# Ensure session directory exists # Ensure session directory exists
self._session_dir.mkdir(parents=True, exist_ok=True) self._session_dir.mkdir(parents=True, exist_ok=True)
# Log command # Log full command
cmd_flags = [c for c in cmd if c.startswith("--")]
logger.info( logger.info(
f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, " f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, "
f"flags={' '.join(cmd_flags)}" f"cmd={' '.join(cmd)}"
) )
try: try:
@ -226,7 +226,7 @@ class ClaudeSubprocess:
# Write NDJSON to stdin # Write NDJSON to stdin
try: try:
msg_dict = {"type": "user", "content": message} msg_dict = {"type": "user", "message": {"role": "user", "content": message}}
ndjson_line = json.dumps(msg_dict) + '\n' ndjson_line = json.dumps(msg_dict) + '\n'
self._process.stdin.write(ndjson_line.encode()) self._process.stdin.write(ndjson_line.encode())
await self._process.stdin.drain() # CRITICAL: flush buffer await self._process.stdin.drain() # CRITICAL: flush buffer
@ -317,6 +317,8 @@ class ClaudeSubprocess:
if event_type == "assistant": if event_type == "assistant":
# Extract text from assistant message # Extract text from assistant message
message = event.get("message", {}) message = event.get("message", {})
model = message.get("model", "unknown")
logger.info(f"Assistant response model: {model}")
content = message.get("content", []) content = message.get("content", [])
for block in content: for block in content:
if block.get("type") == "text": if block.get("type") == "text":

View file

@ -3,7 +3,7 @@
"description": "All-around helpful assistant", "description": "All-around helpful assistant",
"system_prompt": "You are Claude, a helpful AI assistant. Be concise, practical, and direct. Help with whatever is asked — coding, writing, analysis, problem-solving, or just conversation. Adapt your tone and depth to the question.", "system_prompt": "You are Claude, a helpful AI assistant. Be concise, practical, and direct. Help with whatever is asked — coding, writing, analysis, problem-solving, or just conversation. Adapt your tone and depth to the question.",
"settings": { "settings": {
"model": "sonnet", "model": "claude-sonnet-4-5-20250929",
"max_turns": 25 "max_turns": 25
} }
} }

View file

@ -153,7 +153,8 @@ async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event):
""" """
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) result = await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
logger.info(f"Typing indicator sent to chat_id={chat_id}, result={result}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to send typing indicator: {e}") logger.warning(f"Failed to send typing indicator: {e}")