@@ -115,6 +115,7 @@ class MessageOrchestrator:
115115 def __init__ (self , settings : Settings , deps : Dict [str , Any ]):
116116 self .settings = settings
117117 self .deps = deps
118+ self ._known_commands : frozenset [str ] = frozenset ()
118119
119120 def _inject_deps (self , handler : Callable ) -> Callable : # type: ignore[type-arg]
120121 """Wrap handler to inject dependencies into context.bot_data."""
@@ -306,12 +307,16 @@ def _register_agentic_handlers(self, app: Application) -> None:
306307 ("new" , self .agentic_new ),
307308 ("status" , self .agentic_status ),
308309 ("verbose" , self .agentic_verbose ),
310+ ("model" , self .agentic_model ),
309311 ("repo" , self .agentic_repo ),
310312 ("restart" , command .restart_command ),
311313 ]
312314 if self .settings .enable_project_threads :
313315 handlers .append (("sync_threads" , command .sync_threads ))
314316
317+ # Derive known commands dynamically — avoids drift when new commands are added
318+ self ._known_commands : frozenset [str ] = frozenset (cmd for cmd , _ in handlers )
319+
315320 for cmd , handler in handlers :
316321 app .add_handler (CommandHandler (cmd , self ._inject_deps (handler )))
317322
@@ -324,6 +329,19 @@ def _register_agentic_handlers(self, app: Application) -> None:
324329 group = 10 ,
325330 )
326331
332+ # Unknown slash commands -> Claude (passthrough in agentic mode).
333+ # Registered commands are handled by CommandHandlers in group 0
334+ # (higher priority). This catches any /command not matched there
335+ # and forwards it to Claude, while skipping known commands to
336+ # avoid double-firing.
337+ app .add_handler (
338+ MessageHandler (
339+ filters .COMMAND ,
340+ self ._inject_deps (self ._handle_unknown_command ),
341+ ),
342+ group = 10 ,
343+ )
344+
327345 # File uploads -> Claude
328346 app .add_handler (
329347 MessageHandler (
@@ -415,6 +433,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
415433 BotCommand ("new" , "Start a fresh session" ),
416434 BotCommand ("status" , "Show session status" ),
417435 BotCommand ("verbose" , "Set output verbosity (0/1/2)" ),
436+ BotCommand ("model" , "Switch Claude model" ),
418437 BotCommand ("repo" , "List repos / switch workspace" ),
419438 BotCommand ("restart" , "Restart the bot" ),
420439 ]
@@ -578,6 +597,81 @@ async def agentic_verbose(
578597 parse_mode = "HTML" ,
579598 )
580599
600+ def _get_model_override (self , context : ContextTypes .DEFAULT_TYPE ) -> Optional [str ]:
601+ """Return per-user model override, or None to use the default."""
602+ return context .user_data .get ("model_override" )
603+
604+ @staticmethod
605+ def _resolve_model_display (
606+ user_override : Optional [str ],
607+ config_model : Optional [str ],
608+ last_model : Optional [str ] = None ,
609+ ) -> str :
610+ """Return a human-readable model string showing what will actually be used."""
611+ if user_override :
612+ return user_override
613+ if config_model :
614+ return config_model
615+ if last_model :
616+ return last_model
617+ return "unknown (send a message first to detect)"
618+
619+ async def agentic_model (
620+ self , update : Update , context : ContextTypes .DEFAULT_TYPE
621+ ) -> None :
622+ """Set Claude model: /model [model_name]."""
623+ args = update .message .text .split ()[1 :] if update .message .text else []
624+ user_override = self ._get_model_override (context )
625+ last_model = context .user_data .get ("last_model" )
626+ current = self ._resolve_model_display (
627+ user_override , self .settings .claude_model , last_model
628+ )
629+
630+ if not args :
631+ source = "user override" if user_override else (
632+ "server config" if self .settings .claude_model else "Claude Code default"
633+ )
634+ await update .message .reply_text (
635+ f"Model: <b>{ escape_html (current )} </b> ({ source } )\n \n "
636+ "Usage: <code>/model model_name</code>\n "
637+ "Reset: <code>/model default</code>" ,
638+ parse_mode = "HTML" ,
639+ )
640+ return
641+
642+ model_name = args [0 ].strip ()
643+ if not model_name or len (model_name ) > 100 :
644+ await update .message .reply_text ("Invalid model name." )
645+ return
646+ audit_logger = context .bot_data .get ("audit_logger" )
647+ if model_name == "default" :
648+ context .user_data .pop ("model_override" , None )
649+ default = self ._resolve_model_display (None , self .settings .claude_model )
650+ await update .message .reply_text (
651+ f"Model reset to <b>{ escape_html (default )} </b>" ,
652+ parse_mode = "HTML" ,
653+ )
654+ if audit_logger :
655+ await audit_logger .log_command (
656+ user_id = update .effective_user .id ,
657+ command = "model_reset" ,
658+ args = [],
659+ success = True ,
660+ )
661+ else :
662+ context .user_data ["model_override" ] = model_name
663+ await update .message .reply_text (
664+ f"Model set to <b>{ escape_html (model_name )} </b>" ,
665+ parse_mode = "HTML" ,
666+ )
667+ if audit_logger :
668+ await audit_logger .log_command (
669+ user_id = update .effective_user .id ,
670+ command = "model" ,
671+ args = [model_name ],
672+ success = True ,
673+ )
674+
581675 def _format_verbose_progress (
582676 self ,
583677 activity_log : List [Dict [str , Any ]],
@@ -941,13 +1035,16 @@ async def agentic_text(
9411035 session_id = session_id ,
9421036 on_stream = on_stream ,
9431037 force_new = force_new ,
1038+ model_override = self ._get_model_override (context ),
9441039 )
9451040
9461041 # New session created successfully — clear the one-shot flag
9471042 if force_new :
9481043 context .user_data ["force_new_session" ] = False
9491044
9501045 context .user_data ["claude_session_id" ] = claude_response .session_id
1046+ if claude_response .model :
1047+ context .user_data ["last_model" ] = claude_response .model
9511048
9521049 # Track directory changes
9531050 from .handlers .message import _update_working_directory_from_claude_response
@@ -1185,12 +1282,15 @@ async def agentic_document(
11851282 session_id = session_id ,
11861283 on_stream = on_stream ,
11871284 force_new = force_new ,
1285+ model_override = self ._get_model_override (context ),
11881286 )
11891287
11901288 if force_new :
11911289 context .user_data ["force_new_session" ] = False
11921290
11931291 context .user_data ["claude_session_id" ] = claude_response .session_id
1292+ if claude_response .model :
1293+ context .user_data ["last_model" ] = claude_response .model
11941294
11951295 from .handlers .message import _update_working_directory_from_claude_response
11961296
@@ -1384,6 +1484,7 @@ async def _handle_agentic_media_message(
13841484 session_id = session_id ,
13851485 on_stream = on_stream ,
13861486 force_new = force_new ,
1487+ model_override = self ._get_model_override (context ),
13871488 )
13881489 finally :
13891490 heartbeat .cancel ()
@@ -1392,6 +1493,7 @@ async def _handle_agentic_media_message(
13921493 context .user_data ["force_new_session" ] = False
13931494
13941495 context .user_data ["claude_session_id" ] = claude_response .session_id
1496+ context .user_data ["last_model" ] = claude_response .model
13951497
13961498 from .handlers .message import _update_working_directory_from_claude_response
13971499
@@ -1450,6 +1552,25 @@ async def _handle_agentic_media_message(
14501552 except Exception as img_err :
14511553 logger .warning ("Image send failed" , error = str (img_err ))
14521554
1555+ async def _handle_unknown_command (
1556+ self , update : Update , context : ContextTypes .DEFAULT_TYPE
1557+ ) -> None :
1558+ """Forward unknown slash commands to Claude in agentic mode.
1559+
1560+ Known commands are handled by their own CommandHandlers (group 0);
1561+ this handler fires for *every* COMMAND message in group 10 but
1562+ returns immediately when the command is registered, preventing
1563+ double execution.
1564+ """
1565+ msg = update .effective_message
1566+ if not msg or not msg .text :
1567+ return
1568+ cmd = msg .text .split ()[0 ].lstrip ("/" ).split ("@" )[0 ].lower ()
1569+ if cmd in self ._known_commands :
1570+ return # let the registered CommandHandler take care of it
1571+ # Forward unrecognised /commands to Claude as natural language
1572+ await self .agentic_text (update , context )
1573+
14531574 def _voice_unavailable_message (self ) -> str :
14541575 """Return provider-aware guidance when voice feature is unavailable."""
14551576 return (
0 commit comments