vibe-remote 2.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. config/__init__.py +37 -0
  2. config/paths.py +56 -0
  3. config/v2_compat.py +74 -0
  4. config/v2_config.py +206 -0
  5. config/v2_sessions.py +73 -0
  6. config/v2_settings.py +115 -0
  7. core/__init__.py +0 -0
  8. core/controller.py +736 -0
  9. core/handlers/__init__.py +13 -0
  10. core/handlers/command_handlers.py +342 -0
  11. core/handlers/message_handler.py +365 -0
  12. core/handlers/session_handler.py +233 -0
  13. core/handlers/settings_handler.py +362 -0
  14. modules/__init__.py +0 -0
  15. modules/agent_router.py +58 -0
  16. modules/agents/__init__.py +38 -0
  17. modules/agents/base.py +91 -0
  18. modules/agents/claude_agent.py +344 -0
  19. modules/agents/codex_agent.py +368 -0
  20. modules/agents/opencode_agent.py +2155 -0
  21. modules/agents/service.py +41 -0
  22. modules/agents/subagent_router.py +136 -0
  23. modules/claude_client.py +154 -0
  24. modules/im/__init__.py +63 -0
  25. modules/im/base.py +323 -0
  26. modules/im/factory.py +60 -0
  27. modules/im/formatters/__init__.py +4 -0
  28. modules/im/formatters/base_formatter.py +639 -0
  29. modules/im/formatters/slack_formatter.py +127 -0
  30. modules/im/slack.py +2091 -0
  31. modules/session_manager.py +138 -0
  32. modules/settings_manager.py +587 -0
  33. vibe/__init__.py +6 -0
  34. vibe/__main__.py +12 -0
  35. vibe/_version.py +34 -0
  36. vibe/api.py +412 -0
  37. vibe/cli.py +637 -0
  38. vibe/runtime.py +213 -0
  39. vibe/service_main.py +101 -0
  40. vibe/templates/slack_manifest.json +65 -0
  41. vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
  42. vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
  43. vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
  44. vibe/ui/dist/index.html +17 -0
  45. vibe/ui/dist/logo.png +0 -0
  46. vibe/ui/dist/vite.svg +1 -0
  47. vibe/ui_server.py +346 -0
  48. vibe_remote-2.1.6.dist-info/METADATA +295 -0
  49. vibe_remote-2.1.6.dist-info/RECORD +52 -0
  50. vibe_remote-2.1.6.dist-info/WHEEL +4 -0
  51. vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
  52. vibe_remote-2.1.6.dist-info/licenses/LICENSE +21 -0
core/controller.py ADDED
@@ -0,0 +1,736 @@
1
+ """Core controller that coordinates between modules and handlers"""
2
+
3
+ import asyncio
4
+ import os
5
+ import logging
6
+ from typing import Optional, Dict, Any
7
+ from modules.im import BaseIMClient, MessageContext, IMFactory
8
+ from modules.im.formatters import SlackFormatter
9
+ from modules.agent_router import AgentRouter
10
+ from modules.agents import AgentService, ClaudeAgent, CodexAgent, OpenCodeAgent
11
+ from modules.claude_client import ClaudeClient
12
+ from modules.session_manager import SessionManager
13
+ from modules.settings_manager import SettingsManager
14
+ from core.handlers import (
15
+ CommandHandlers,
16
+ SessionHandler,
17
+ SettingsHandler,
18
+ MessageHandler,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Controller:
25
+ """Main controller that coordinates all bot operations"""
26
+
27
+ def __init__(self, config):
28
+ """Initialize controller with configuration"""
29
+ self.config = config
30
+
31
+ # Session tracking (must be initialized before handlers)
32
+ self.claude_sessions: Dict[str, Any] = {}
33
+ self.receiver_tasks: Dict[str, asyncio.Task] = {}
34
+ self.stored_session_mappings: Dict[str, str] = {}
35
+
36
+ # Consolidated message tracking (system/assistant/toolcall)
37
+ self._consolidated_message_ids: Dict[str, str] = {}
38
+ self._consolidated_message_buffers: Dict[str, str] = {}
39
+ self._consolidated_message_locks: Dict[str, asyncio.Lock] = {}
40
+
41
+ # Initialize core modules
42
+ self._init_modules()
43
+
44
+ # Initialize handlers
45
+ self._init_handlers()
46
+
47
+ # Initialize agents (depends on handlers/session handler)
48
+ self._init_agents()
49
+
50
+ # Setup callbacks
51
+ self._setup_callbacks()
52
+
53
+ # Background task for cleanup
54
+ self.cleanup_task: Optional[asyncio.Task] = None
55
+
56
+ # Restore session mappings on startup (after handlers are initialized)
57
+ self.session_handler.restore_session_mappings()
58
+
59
+ def _init_modules(self):
60
+ """Initialize core modules"""
61
+ # Create IM client with platform-specific formatter
62
+ self.im_client: BaseIMClient = IMFactory.create_client(self.config)
63
+
64
+ # Create platform-specific formatter
65
+ formatter = SlackFormatter()
66
+
67
+ # Inject formatter into clients
68
+ self.im_client.formatter = formatter
69
+ self.claude_client = ClaudeClient(self.config.claude, formatter)
70
+
71
+ # Initialize managers
72
+ self.session_manager = SessionManager()
73
+ self.settings_manager = SettingsManager()
74
+
75
+ # Agent routing
76
+ self.agent_router = AgentRouter.from_file(None, platform=self.config.platform)
77
+
78
+ # Default backend preference:
79
+ # If OpenCode is enabled, make it the implicit default backend.
80
+ if self.config.opencode:
81
+ self.agent_router.global_default = "opencode"
82
+ platform_route = self.agent_router.platform_routes.get(self.config.platform)
83
+ if platform_route:
84
+ platform_route.default = "opencode"
85
+
86
+ # Inject settings_manager into SlackBot if it's Slack platform
87
+ if self.config.platform == "slack":
88
+ # Import here to avoid circular dependency
89
+ from modules.im.slack import SlackBot
90
+ if isinstance(self.im_client, SlackBot):
91
+ self.im_client.set_settings_manager(self.settings_manager)
92
+ logger.info("Injected settings_manager into SlackBot for thread tracking")
93
+
94
+ def _init_handlers(self):
95
+ """Initialize all handlers with controller reference"""
96
+ # Initialize session_handler first as other handlers depend on it
97
+ self.session_handler = SessionHandler(self)
98
+ self.command_handler = CommandHandlers(self)
99
+ self.settings_handler = SettingsHandler(self)
100
+ self.message_handler = MessageHandler(self)
101
+
102
+ # Set cross-references between handlers
103
+ self.message_handler.set_session_handler(self.session_handler)
104
+
105
+ def _init_agents(self):
106
+ self.agent_service = AgentService(self)
107
+ self.agent_service.register(ClaudeAgent(self))
108
+ if self.config.codex:
109
+ try:
110
+ self.agent_service.register(CodexAgent(self, self.config.codex))
111
+ except Exception as e:
112
+ logger.error(f"Failed to initialize Codex agent: {e}")
113
+ if self.config.opencode:
114
+ try:
115
+ self.agent_service.register(OpenCodeAgent(self, self.config.opencode))
116
+ except Exception as e:
117
+ logger.error(f"Failed to initialize OpenCode agent: {e}")
118
+
119
+ def _setup_callbacks(self):
120
+ """Setup callback connections between modules"""
121
+ # Create command handlers dict
122
+ command_handlers = {
123
+ "start": self.command_handler.handle_start,
124
+ "clear": self.command_handler.handle_clear,
125
+ "cwd": self.command_handler.handle_cwd,
126
+ "set_cwd": self.command_handler.handle_set_cwd,
127
+ "settings": self.settings_handler.handle_settings,
128
+ "stop": self.command_handler.handle_stop,
129
+ }
130
+
131
+ # Register callbacks with the IM client
132
+ self.im_client.register_callbacks(
133
+ on_message=self.message_handler.handle_user_message,
134
+ on_command=command_handlers,
135
+ on_callback_query=self.message_handler.handle_callback_query,
136
+ on_settings_update=self.handle_settings_update,
137
+ on_change_cwd=self.handle_change_cwd_submission,
138
+ on_routing_update=self.handle_routing_update,
139
+ on_routing_modal_update=self.handle_routing_modal_update,
140
+ )
141
+
142
+ # Utility methods used by handlers
143
+
144
+ def get_cwd(self, context: MessageContext) -> str:
145
+ """Get working directory based on context (channel/chat)
146
+ This is the SINGLE source of truth for CWD
147
+ """
148
+ # Get the settings key based on context
149
+ settings_key = self._get_settings_key(context)
150
+
151
+ # Get custom CWD from settings
152
+ custom_cwd = self.settings_manager.get_custom_cwd(settings_key)
153
+
154
+ # Use custom CWD if available, otherwise use default from config
155
+ if custom_cwd:
156
+ abs_path = os.path.abspath(os.path.expanduser(custom_cwd))
157
+ if os.path.exists(abs_path):
158
+ return abs_path
159
+ # Try to create it
160
+ try:
161
+ os.makedirs(abs_path, exist_ok=True)
162
+ logger.info(f"Created custom CWD: {abs_path}")
163
+ return abs_path
164
+ except OSError as e:
165
+ logger.warning(f"Failed to create custom CWD '{abs_path}': {e}, using default")
166
+
167
+ # Fall back to default from config.json
168
+ default_cwd = self.config.claude.cwd
169
+ if default_cwd:
170
+ return os.path.abspath(os.path.expanduser(default_cwd))
171
+
172
+ # Last resort: current directory
173
+ return os.getcwd()
174
+
175
+ def _get_settings_key(self, context: MessageContext) -> str:
176
+ """Get settings key based on context"""
177
+ # Slack only in V2
178
+ return context.channel_id
179
+
180
+ def _get_target_context(self, context: MessageContext) -> MessageContext:
181
+ """Get target context for sending messages"""
182
+ if self.im_client.should_use_thread_for_reply() and context.thread_id:
183
+ return MessageContext(
184
+ user_id=context.user_id,
185
+ channel_id=context.channel_id,
186
+ thread_id=context.thread_id,
187
+ message_id=context.message_id,
188
+ platform_specific=context.platform_specific,
189
+ )
190
+ return context
191
+
192
+ def _get_consolidated_message_key(self, context: MessageContext) -> str:
193
+ settings_key = self._get_settings_key(context)
194
+ thread_key = context.thread_id or context.channel_id
195
+ return f"{settings_key}:{thread_key}"
196
+
197
+ def _get_consolidated_message_lock(self, key: str) -> asyncio.Lock:
198
+ if key not in self._consolidated_message_locks:
199
+ self._consolidated_message_locks[key] = asyncio.Lock()
200
+ return self._consolidated_message_locks[key]
201
+
202
+ def _get_consolidated_max_chars(self) -> int:
203
+ # Slack max message length is ~40k characters.
204
+ return 35000
205
+
206
+ def _get_result_max_chars(self) -> int:
207
+ return 30000
208
+
209
+ def _build_result_summary(self, text: str, max_chars: int) -> str:
210
+ if len(text) <= max_chars:
211
+ return text
212
+ prefix = "Result too long; showing a summary.\n\n"
213
+ suffix = "\n\n…(truncated; see result.md for full output)"
214
+ keep = max(0, max_chars - len(prefix) - len(suffix))
215
+ return f"{prefix}{text[:keep]}{suffix}"
216
+
217
+ def _truncate_consolidated(self, text: str, max_chars: int) -> str:
218
+ if len(text) <= max_chars:
219
+ return text
220
+ prefix = "…(truncated)…\n\n"
221
+ keep = max(0, max_chars - len(prefix))
222
+ return f"{prefix}{text[-keep:]}"
223
+
224
+ def resolve_agent_for_context(self, context: MessageContext) -> str:
225
+ """Unified agent resolution with dynamic override support.
226
+
227
+ Priority:
228
+ 1. channel_routing.agent_backend (from settings.json)
229
+ 2. AgentRouter platform default (configured in code)
230
+ 3. AgentService.default_agent ("claude")
231
+ """
232
+ settings_key = self._get_settings_key(context)
233
+
234
+ # Check dynamic override first
235
+ routing = self.settings_manager.get_channel_routing(settings_key)
236
+ if routing and routing.agent_backend:
237
+ # Verify the agent is registered
238
+ if routing.agent_backend in self.agent_service.agents:
239
+ return routing.agent_backend
240
+ else:
241
+ logger.warning(
242
+ f"Channel routing specifies '{routing.agent_backend}' but agent is not registered, "
243
+ f"falling back to static routing"
244
+ )
245
+
246
+ # Fall back to static routing
247
+ resolved = self.agent_router.resolve(self.config.platform, settings_key)
248
+
249
+ return resolved
250
+
251
+ def get_opencode_overrides(
252
+ self, context: MessageContext
253
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
254
+ """Get OpenCode agent, model, and reasoning effort overrides for this channel.
255
+
256
+ Returns:
257
+ Tuple of (opencode_agent, opencode_model, opencode_reasoning_effort)
258
+ or (None, None, None) if no overrides.
259
+ """
260
+ settings_key = self._get_settings_key(context)
261
+ routing = self.settings_manager.get_channel_routing(settings_key)
262
+ if routing:
263
+ return (
264
+ routing.opencode_agent,
265
+ routing.opencode_model,
266
+ routing.opencode_reasoning_effort,
267
+ )
268
+ return None, None, None
269
+
270
+ async def emit_agent_message(
271
+ self,
272
+ context: MessageContext,
273
+ message_type: str,
274
+ text: str,
275
+ parse_mode: Optional[str] = "markdown",
276
+ ):
277
+ """Centralized dispatch for agent messages.
278
+
279
+ - notify: always send immediately
280
+ - result: always send immediately (not hideable)
281
+ - system/assistant/toolcall: consolidate into a single editable message per thread
282
+ """
283
+ if not text or not text.strip():
284
+ return
285
+
286
+ canonical_type = self.settings_manager._canonicalize_message_type(
287
+ message_type or ""
288
+ )
289
+ settings_key = self._get_settings_key(context)
290
+
291
+ if canonical_type == "notify":
292
+ target_context = self._get_target_context(context)
293
+ await self.im_client.send_message(
294
+ target_context, text, parse_mode=parse_mode
295
+ )
296
+ return
297
+
298
+ if canonical_type == "result":
299
+ target_context = self._get_target_context(context)
300
+ if len(text) <= self._get_result_max_chars():
301
+ await self.im_client.send_message(
302
+ target_context, text, parse_mode=parse_mode
303
+ )
304
+ return
305
+
306
+ summary = self._build_result_summary(text, self._get_result_max_chars())
307
+ await self.im_client.send_message(
308
+ target_context, summary, parse_mode=parse_mode
309
+ )
310
+
311
+ if self.config.platform == "slack" and hasattr(self.im_client, "upload_markdown"):
312
+ try:
313
+ await self.im_client.upload_markdown(
314
+ target_context,
315
+ title="result.md",
316
+ content=text,
317
+ filetype="markdown",
318
+ )
319
+ except Exception as err:
320
+ logger.warning(f"Failed to upload result attachment: {err}")
321
+ await self.im_client.send_message(
322
+ target_context,
323
+ "无法上传附件(缺少 files:write 权限或上传失败)。需要我改成分条发送吗?",
324
+ parse_mode=parse_mode,
325
+ )
326
+ return
327
+
328
+ if canonical_type not in {"system", "assistant", "toolcall"}:
329
+ canonical_type = "assistant"
330
+
331
+ if self.settings_manager.is_message_type_hidden(settings_key, canonical_type):
332
+ preview = text if len(text) <= 500 else f"{text[:500]}…"
333
+ logger.info(
334
+ "Skipping %s message for settings %s (hidden). Preview: %s",
335
+ canonical_type,
336
+ settings_key,
337
+ preview,
338
+ )
339
+ return
340
+
341
+ consolidated_key = self._get_consolidated_message_key(context)
342
+ lock = self._get_consolidated_message_lock(consolidated_key)
343
+
344
+ async with lock:
345
+ chunk = text.strip()
346
+ existing = self._consolidated_message_buffers.get(consolidated_key, "")
347
+ separator = "\n\n---\n\n" if existing else ""
348
+ updated = f"{existing}{separator}{chunk}" if existing else chunk
349
+
350
+ updated = self._truncate_consolidated(updated, self._get_consolidated_max_chars())
351
+ self._consolidated_message_buffers[consolidated_key] = updated
352
+
353
+ target_context = self._get_target_context(context)
354
+ existing_message_id = self._consolidated_message_ids.get(consolidated_key)
355
+ if existing_message_id:
356
+ try:
357
+ ok = await self.im_client.edit_message(
358
+ target_context,
359
+ existing_message_id,
360
+ text=updated,
361
+ parse_mode="markdown",
362
+ )
363
+ except Exception as err:
364
+ logger.warning(f"Failed to edit consolidated message: {err}")
365
+ ok = False
366
+ if ok:
367
+ return
368
+ self._consolidated_message_ids.pop(consolidated_key, None)
369
+
370
+ try:
371
+ new_id = await self.im_client.send_message(
372
+ target_context, updated, parse_mode="markdown"
373
+ )
374
+ self._consolidated_message_ids[consolidated_key] = new_id
375
+ except Exception as err:
376
+ logger.error(f"Failed to send consolidated message: {err}", exc_info=True)
377
+
378
+ # Settings update handler (for Slack modal)
379
+ async def handle_settings_update(
380
+ self, user_id: str, show_message_types: list, channel_id: Optional[str] = None,
381
+ require_mention: Optional[bool] = None,
382
+ ):
383
+ """Handle settings update (typically from Slack modal)"""
384
+ try:
385
+ # Determine settings key - for Slack, always use channel_id
386
+ if self.config.platform == "slack":
387
+ settings_key = (
388
+ channel_id if channel_id else user_id
389
+ ) # fallback to user_id if no channel
390
+ else:
391
+ settings_key = channel_id if channel_id else user_id
392
+
393
+ # Update settings
394
+ user_settings = self.settings_manager.get_user_settings(settings_key)
395
+ user_settings.show_message_types = show_message_types
396
+
397
+ # Save settings - using the correct method name
398
+ self.settings_manager.update_user_settings(settings_key, user_settings)
399
+
400
+ # Save require_mention setting
401
+ self.settings_manager.set_require_mention(settings_key, require_mention)
402
+
403
+ logger.info(
404
+ f"Updated settings for {settings_key}: show types = {show_message_types}, "
405
+ f"require_mention = {require_mention}"
406
+ )
407
+
408
+ # Create context for sending confirmation (without 'message' field)
409
+ context = MessageContext(
410
+ user_id=user_id,
411
+ channel_id=channel_id if channel_id else user_id,
412
+ platform_specific={},
413
+ )
414
+
415
+ # Send confirmation
416
+ await self.im_client.send_message(
417
+ context, "✅ Settings updated successfully!"
418
+ )
419
+
420
+ except Exception as e:
421
+ logger.error(f"Error updating settings: {e}")
422
+ # Create context for error message (without 'message' field)
423
+ context = MessageContext(
424
+ user_id=user_id,
425
+ channel_id=channel_id if channel_id else user_id,
426
+ platform_specific={},
427
+ )
428
+ await self.im_client.send_message(
429
+ context, f"❌ Failed to update settings: {str(e)}"
430
+ )
431
+
432
+ # Working directory change handler (for Slack modal)
433
+ async def handle_change_cwd_submission(
434
+ self, user_id: str, new_cwd: str, channel_id: Optional[str] = None
435
+ ):
436
+ """Handle working directory change submission (from Slack modal) - reuse command handler logic"""
437
+ try:
438
+ # Create context for messages (without 'message' field which doesn't exist in MessageContext)
439
+ context = MessageContext(
440
+ user_id=user_id,
441
+ channel_id=channel_id if channel_id else user_id,
442
+ platform_specific={},
443
+ )
444
+
445
+ # Reuse the same logic from handle_set_cwd command handler
446
+ await self.command_handler.handle_set_cwd(context, new_cwd.strip())
447
+
448
+ except Exception as e:
449
+ logger.error(f"Error changing working directory: {e}")
450
+ # Create context for error message (without 'message' field)
451
+ context = MessageContext(
452
+ user_id=user_id,
453
+ channel_id=channel_id if channel_id else user_id,
454
+ platform_specific={},
455
+ )
456
+ await self.im_client.send_message(
457
+ context, f"❌ Failed to change working directory: {str(e)}"
458
+ )
459
+
460
+ async def handle_routing_modal_update(
461
+ self,
462
+ user_id: str,
463
+ channel_id: str,
464
+ view: dict,
465
+ action: dict,
466
+ ) -> None:
467
+ """Handle routing modal updates when selections change."""
468
+ try:
469
+ view_id = view.get("id")
470
+ view_hash = view.get("hash")
471
+ if not view_id or not view_hash:
472
+ logger.warning("Routing modal update missing view id/hash")
473
+ return
474
+
475
+ resolved_channel_id = channel_id if channel_id else user_id
476
+ context = MessageContext(
477
+ user_id=user_id,
478
+ channel_id=resolved_channel_id,
479
+ platform_specific={},
480
+ )
481
+
482
+ settings_key = self._get_settings_key(context)
483
+ current_routing = self.settings_manager.get_channel_routing(settings_key)
484
+ all_backends = list(self.agent_service.agents.keys())
485
+ registered_backends = sorted(
486
+ all_backends, key=lambda x: (x != "opencode", x)
487
+ )
488
+ current_backend = self.resolve_agent_for_context(context)
489
+
490
+ values = view.get("state", {}).get("values", {})
491
+ backend_data = values.get("backend_block", {}).get("backend_select", {})
492
+ selected_backend = backend_data.get("selected_option", {}).get("value")
493
+ if not selected_backend:
494
+ selected_backend = current_backend
495
+
496
+ def _selected_value(block_id: str, action_id: str) -> Optional[str]:
497
+ data = values.get(block_id, {}).get(action_id, {})
498
+ return data.get("selected_option", {}).get("value")
499
+
500
+ def _selected_prefixed_value(
501
+ block_id: str, action_prefix: str
502
+ ) -> Optional[str]:
503
+ block = values.get(block_id, {})
504
+ if not isinstance(block, dict):
505
+ return None
506
+ for action_id, action_data in block.items():
507
+ if (
508
+ isinstance(action_id, str)
509
+ and action_id.startswith(action_prefix)
510
+ and isinstance(action_data, dict)
511
+ ):
512
+ return action_data.get("selected_option", {}).get("value")
513
+ return None
514
+
515
+ oc_agent = _selected_value("opencode_agent_block", "opencode_agent_select")
516
+ oc_model = _selected_value("opencode_model_block", "opencode_model_select")
517
+ oc_reasoning = _selected_prefixed_value(
518
+ "opencode_reasoning_block", "opencode_reasoning_select"
519
+ )
520
+
521
+ # For block_actions, the latest selection is carried on the `action` payload.
522
+ action_id = action.get("action_id")
523
+ selected_value = None
524
+ selected_option = action.get("selected_option")
525
+ if isinstance(selected_option, dict):
526
+ selected_value = selected_option.get("value")
527
+
528
+ if isinstance(action_id, str) and isinstance(selected_value, str):
529
+ if action_id == "opencode_agent_select":
530
+ oc_agent = selected_value
531
+ elif action_id == "opencode_model_select":
532
+ oc_model = selected_value
533
+ elif action_id.startswith("opencode_reasoning_select"):
534
+ oc_reasoning = selected_value
535
+
536
+ if oc_agent == "__default__":
537
+ oc_agent = None
538
+ if oc_model == "__default__":
539
+ oc_model = None
540
+ if oc_reasoning == "__default__":
541
+ oc_reasoning = None
542
+
543
+ opencode_agents = []
544
+ opencode_models = {}
545
+ opencode_default_config = {}
546
+
547
+ if "opencode" in registered_backends:
548
+ try:
549
+ opencode_agent = self.agent_service.agents.get("opencode")
550
+ if opencode_agent and hasattr(opencode_agent, "_get_server"):
551
+ server = await opencode_agent._get_server() # type: ignore[attr-defined]
552
+ await server.ensure_running()
553
+ cwd = self.get_cwd(context)
554
+ opencode_agents = await server.get_available_agents(cwd)
555
+ opencode_models = await server.get_available_models(cwd)
556
+ opencode_default_config = await server.get_default_config(cwd)
557
+ except Exception as e:
558
+ logger.warning(f"Failed to fetch OpenCode data: {e}")
559
+
560
+ if hasattr(self.im_client, "update_routing_modal"):
561
+ await self.im_client.update_routing_modal( # type: ignore[attr-defined]
562
+ view_id=view_id,
563
+ view_hash=view_hash,
564
+ channel_id=resolved_channel_id,
565
+ registered_backends=registered_backends,
566
+ current_backend=current_backend,
567
+ current_routing=current_routing,
568
+ opencode_agents=opencode_agents,
569
+ opencode_models=opencode_models,
570
+ opencode_default_config=opencode_default_config,
571
+ selected_backend=selected_backend,
572
+ selected_opencode_agent=oc_agent,
573
+ selected_opencode_model=oc_model,
574
+ selected_opencode_reasoning=oc_reasoning,
575
+ )
576
+ except Exception as e:
577
+ logger.error(f"Error updating routing modal: {e}", exc_info=True)
578
+
579
+ # Routing update handler (for Slack modal)
580
+ async def handle_routing_update(
581
+ self,
582
+ user_id: str,
583
+ channel_id: str,
584
+ backend: str,
585
+ opencode_agent: Optional[str],
586
+ opencode_model: Optional[str],
587
+ opencode_reasoning_effort: Optional[str] = None,
588
+ require_mention: Optional[bool] = None,
589
+ ):
590
+ """Handle routing update submission (from Slack modal)"""
591
+ from modules.settings_manager import ChannelRouting
592
+
593
+ try:
594
+ # Create routing object
595
+ routing = ChannelRouting(
596
+ agent_backend=backend,
597
+ opencode_agent=opencode_agent,
598
+ opencode_model=opencode_model,
599
+ opencode_reasoning_effort=opencode_reasoning_effort,
600
+ )
601
+
602
+ # Get settings key
603
+ settings_key = channel_id if channel_id else user_id
604
+
605
+ # Save routing
606
+ self.settings_manager.set_channel_routing(settings_key, routing)
607
+
608
+ # Save require_mention setting
609
+ self.settings_manager.set_require_mention(settings_key, require_mention)
610
+
611
+ # Build confirmation message
612
+ parts = [f"Backend: **{backend}**"]
613
+ if backend == "opencode":
614
+ if opencode_agent:
615
+ parts.append(f"Agent: **{opencode_agent}**")
616
+ if opencode_model:
617
+ parts.append(f"Model: **{opencode_model}**")
618
+ if opencode_reasoning_effort:
619
+ parts.append(f"Reasoning Effort: **{opencode_reasoning_effort}**")
620
+
621
+ # Add require_mention status to confirmation
622
+ if require_mention is None:
623
+ parts.append("Require @mention: **(Default)**")
624
+ elif require_mention:
625
+ parts.append("Require @mention: **Yes**")
626
+ else:
627
+ parts.append("Require @mention: **No**")
628
+
629
+ # Create context for confirmation message
630
+ context = MessageContext(
631
+ user_id=user_id,
632
+ channel_id=channel_id if channel_id else user_id,
633
+ platform_specific={},
634
+ )
635
+
636
+ await self.im_client.send_message(
637
+ context,
638
+ f"✅ Agent routing updated!\n" + "\n".join(parts),
639
+ parse_mode="markdown",
640
+ )
641
+
642
+ logger.info(
643
+ f"Routing updated for {settings_key}: backend={backend}, "
644
+ f"agent={opencode_agent}, model={opencode_model}, require_mention={require_mention}"
645
+ )
646
+
647
+ except Exception as e:
648
+ logger.error(f"Error updating routing: {e}")
649
+ context = MessageContext(
650
+ user_id=user_id,
651
+ channel_id=channel_id if channel_id else user_id,
652
+ platform_specific={},
653
+ )
654
+ await self.im_client.send_message(
655
+ context, f"❌ Failed to update routing: {str(e)}"
656
+ )
657
+
658
+ # Main run method
659
+ def run(self):
660
+ """Run the controller"""
661
+ logger.info(
662
+ f"Starting Claude Proxy Controller with {self.config.platform} platform..."
663
+ )
664
+
665
+ # 不再创建额外事件循环,避免与 IM 客户端的内部事件循环冲突
666
+ # 清理职责改为:
667
+ # - 进程退出时做一次同步的 best-effort 取消(不跨循环 await)
668
+
669
+ try:
670
+ # Run the IM client (blocking)
671
+ self.im_client.run()
672
+ except KeyboardInterrupt:
673
+ logger.info("Received keyboard interrupt, shutting down...")
674
+ except Exception as e:
675
+ logger.error(f"Error in main run loop: {e}", exc_info=True)
676
+ finally:
677
+ # Best-effort 同步清理,避免跨事件循环 await
678
+ self.cleanup_sync()
679
+
680
+ async def periodic_cleanup(self):
681
+ """[Deprecated] Periodic cleanup is disabled in favor of safe on-demand cleanup"""
682
+ logger.info("periodic_cleanup is deprecated and not scheduled.")
683
+ return
684
+
685
+ def cleanup_sync(self):
686
+ """Best-effort synchronous cleanup without cross-loop awaits"""
687
+ logger.info("Cleaning up controller resources (sync, best-effort)...")
688
+
689
+ # Cancel receiver tasks without awaiting (they may belong to other loops)
690
+ try:
691
+ for session_id, task in list(self.receiver_tasks.items()):
692
+ if not task.done():
693
+ task.cancel()
694
+ # Remove from registry regardless
695
+ del self.receiver_tasks[session_id]
696
+ except Exception as e:
697
+ logger.debug(f"Receiver tasks cleanup skipped due to: {e}")
698
+
699
+ # Do not attempt to await SessionHandler cleanup here to avoid cross-loop issues.
700
+ # Active connections will be closed by process exit; mappings are persisted separately.
701
+
702
+ # Attempt to call stop if it's a plain function; skip if coroutine to avoid cross-loop awaits
703
+ try:
704
+ stop_attr = getattr(self.im_client, "stop", None)
705
+ if callable(stop_attr):
706
+ import inspect
707
+
708
+ if not inspect.iscoroutinefunction(stop_attr):
709
+ stop_attr()
710
+ except Exception:
711
+ pass
712
+
713
+ # Best-effort async shutdown for IM clients
714
+ try:
715
+ shutdown_attr = getattr(self.im_client, "shutdown", None)
716
+ if callable(shutdown_attr):
717
+ import inspect
718
+
719
+ if inspect.iscoroutinefunction(shutdown_attr):
720
+ try:
721
+ asyncio.run(shutdown_attr())
722
+ except RuntimeError:
723
+ pass
724
+ else:
725
+ shutdown_attr()
726
+ except Exception:
727
+ pass
728
+
729
+ # Stop OpenCode server if running
730
+ try:
731
+ from modules.agents.opencode_agent import OpenCodeServerManager
732
+ OpenCodeServerManager.stop_instance_sync()
733
+ except Exception as e:
734
+ logger.debug(f"OpenCode server cleanup skipped: {e}")
735
+
736
+ logger.info("Controller cleanup (sync) complete")