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."""
return user_id in get_authorized_users()
def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
"""Create callbacks for ClaudeSubprocess bound to specific chat with typing control."""
def make_callbacks(bot, chat_id, session_name: str):
"""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):
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}")
await bot.send_message(chat_id=chat_id, text=chunk)
# Stop typing indicator
stop_typing_event.set()
# Stop typing indicator (dynamic lookup)
_stop_typing()
elapsed = time.monotonic() - t0
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):
await bot.send_message(chat_id=chat_id, text=f"Error: {error}")
# Stop typing indicator on error
stop_typing_event.set()
_stop_typing()
async def on_complete():
# Stop typing indicator on completion
stop_typing_event.set()
_stop_typing()
async def on_status(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_data = session_manager.load_persona(persona or 'default')
# Create stop typing event for this session
stop_typing = asyncio.Event()
# Create callbacks bound to this chat
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
# Create callbacks bound to this chat (typing looked up dynamically)
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
# Create subprocess
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_data = session_manager.load_persona(persona_name)
# Create stop typing event for this session
stop_typing = asyncio.Event()
# Create callbacks bound to this chat
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
# Create callbacks bound to this chat (typing looked up dynamically)
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
# Create subprocess
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]}...")
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:
stop_typing = asyncio.Event()
typing_task = asyncio.create_task(
typing_indicator_loop(context.bot, update.effective_chat.id, 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)
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_data = session_manager.load_persona(persona_name)
# Create callbacks bound to this chat with stop_typing event
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
# Create callbacks bound to this chat (typing looked up dynamically)
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
# Create subprocess
subprocess_inst = ClaudeSubprocess(
@ -652,6 +659,12 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Get caption if any
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
if active_session not in typing_tasks:
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_data = session_manager.load_persona(persona_name)
stop_typing = typing_tasks[active_session][1]
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
subprocess_inst = ClaudeSubprocess(
session_dir=session_dir,
@ -724,6 +736,12 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Get caption if any
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
if active_session not in typing_tasks:
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_data = session_manager.load_persona(persona_name)
stop_typing = typing_tasks[active_session][1]
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
subprocess_inst = ClaudeSubprocess(
session_dir=session_dir,

View file

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

View file

@ -3,7 +3,7 @@
"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.",
"settings": {
"model": "sonnet",
"model": "claude-sonnet-4-5-20250929",
"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():
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:
logger.warning(f"Failed to send typing indicator: {e}")