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.
- config/__init__.py +37 -0
- config/paths.py +56 -0
- config/v2_compat.py +74 -0
- config/v2_config.py +206 -0
- config/v2_sessions.py +73 -0
- config/v2_settings.py +115 -0
- core/__init__.py +0 -0
- core/controller.py +736 -0
- core/handlers/__init__.py +13 -0
- core/handlers/command_handlers.py +342 -0
- core/handlers/message_handler.py +365 -0
- core/handlers/session_handler.py +233 -0
- core/handlers/settings_handler.py +362 -0
- modules/__init__.py +0 -0
- modules/agent_router.py +58 -0
- modules/agents/__init__.py +38 -0
- modules/agents/base.py +91 -0
- modules/agents/claude_agent.py +344 -0
- modules/agents/codex_agent.py +368 -0
- modules/agents/opencode_agent.py +2155 -0
- modules/agents/service.py +41 -0
- modules/agents/subagent_router.py +136 -0
- modules/claude_client.py +154 -0
- modules/im/__init__.py +63 -0
- modules/im/base.py +323 -0
- modules/im/factory.py +60 -0
- modules/im/formatters/__init__.py +4 -0
- modules/im/formatters/base_formatter.py +639 -0
- modules/im/formatters/slack_formatter.py +127 -0
- modules/im/slack.py +2091 -0
- modules/session_manager.py +138 -0
- modules/settings_manager.py +587 -0
- vibe/__init__.py +6 -0
- vibe/__main__.py +12 -0
- vibe/_version.py +34 -0
- vibe/api.py +412 -0
- vibe/cli.py +637 -0
- vibe/runtime.py +213 -0
- vibe/service_main.py +101 -0
- vibe/templates/slack_manifest.json +65 -0
- vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
- vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
- vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
- vibe/ui/dist/index.html +17 -0
- vibe/ui/dist/logo.png +0 -0
- vibe/ui/dist/vite.svg +1 -0
- vibe/ui_server.py +346 -0
- vibe_remote-2.1.6.dist-info/METADATA +295 -0
- vibe_remote-2.1.6.dist-info/RECORD +52 -0
- vibe_remote-2.1.6.dist-info/WHEEL +4 -0
- vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
- 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")
|