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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Message routing and Agent communication handlers"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from modules.agents import AgentRequest
|
|
7
|
+
from modules.im import MessageContext
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageHandler:
|
|
13
|
+
"""Handles message routing and Claude communication"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, controller):
|
|
16
|
+
"""Initialize with reference to main controller"""
|
|
17
|
+
self.controller = controller
|
|
18
|
+
self.config = controller.config
|
|
19
|
+
self.im_client = controller.im_client
|
|
20
|
+
self.session_manager = controller.session_manager
|
|
21
|
+
self.settings_manager = controller.settings_manager
|
|
22
|
+
self.formatter = controller.im_client.formatter
|
|
23
|
+
self.session_handler = None # Will be set after creation
|
|
24
|
+
self.receiver_tasks = controller.receiver_tasks
|
|
25
|
+
|
|
26
|
+
def set_session_handler(self, session_handler):
|
|
27
|
+
"""Set reference to session handler"""
|
|
28
|
+
self.session_handler = session_handler
|
|
29
|
+
|
|
30
|
+
def _get_settings_key(self, context: MessageContext) -> str:
|
|
31
|
+
"""Get settings key - delegate to controller"""
|
|
32
|
+
return self.controller._get_settings_key(context)
|
|
33
|
+
|
|
34
|
+
def _get_target_context(self, context: MessageContext) -> MessageContext:
|
|
35
|
+
"""Get target context for sending messages"""
|
|
36
|
+
# For Slack, use thread for replies if enabled
|
|
37
|
+
if self.im_client.should_use_thread_for_reply() and context.thread_id:
|
|
38
|
+
return MessageContext(
|
|
39
|
+
user_id=context.user_id,
|
|
40
|
+
channel_id=context.channel_id,
|
|
41
|
+
thread_id=context.thread_id,
|
|
42
|
+
message_id=context.message_id,
|
|
43
|
+
platform_specific=context.platform_specific,
|
|
44
|
+
)
|
|
45
|
+
return context
|
|
46
|
+
|
|
47
|
+
async def handle_user_message(self, context: MessageContext, message: str):
|
|
48
|
+
"""Process regular user messages and route to configured agent"""
|
|
49
|
+
try:
|
|
50
|
+
# If message is empty (e.g., user just @mentioned bot without text),
|
|
51
|
+
# trigger the /start command instead of sending empty message to agent
|
|
52
|
+
if not message or not message.strip():
|
|
53
|
+
await self.controller.command_handler.handle_start(context, "")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Skip automatic cleanup; receiver tasks are retained until shutdown
|
|
57
|
+
|
|
58
|
+
# Allow "stop" shortcut inside Slack threads
|
|
59
|
+
if context.thread_id and message.strip().lower() in ["stop", "/stop"]:
|
|
60
|
+
if await self._handle_inline_stop(context):
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if not self.session_handler:
|
|
64
|
+
raise RuntimeError("Session handler not initialized")
|
|
65
|
+
|
|
66
|
+
base_session_id, working_path, composite_key = (
|
|
67
|
+
self.session_handler.get_session_info(context)
|
|
68
|
+
)
|
|
69
|
+
settings_key = self._get_settings_key(context)
|
|
70
|
+
|
|
71
|
+
agent_name = self.controller.resolve_agent_for_context(context)
|
|
72
|
+
|
|
73
|
+
matched_prefix = None
|
|
74
|
+
subagent_message = None
|
|
75
|
+
subagent_name = None
|
|
76
|
+
subagent_model = None
|
|
77
|
+
subagent_reasoning_effort = None
|
|
78
|
+
|
|
79
|
+
if agent_name in ["opencode", "claude"]:
|
|
80
|
+
from modules.agents.subagent_router import (
|
|
81
|
+
load_claude_subagent,
|
|
82
|
+
normalize_subagent_name,
|
|
83
|
+
parse_subagent_prefix,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
parsed = parse_subagent_prefix(message)
|
|
87
|
+
if parsed:
|
|
88
|
+
normalized = normalize_subagent_name(parsed.name)
|
|
89
|
+
if agent_name == "opencode":
|
|
90
|
+
try:
|
|
91
|
+
opencode_agent = self.controller.agent_service.agents.get("opencode")
|
|
92
|
+
if opencode_agent and hasattr(opencode_agent, "_get_server"):
|
|
93
|
+
server = await opencode_agent._get_server()
|
|
94
|
+
await server.ensure_running()
|
|
95
|
+
opencode_agents = await server.get_available_agents(
|
|
96
|
+
self.controller.get_cwd(context)
|
|
97
|
+
)
|
|
98
|
+
name_map = {
|
|
99
|
+
normalize_subagent_name(a.get("name", "")): a
|
|
100
|
+
for a in opencode_agents
|
|
101
|
+
if a.get("name")
|
|
102
|
+
}
|
|
103
|
+
match = name_map.get(normalized)
|
|
104
|
+
if match:
|
|
105
|
+
subagent_name = match.get("name")
|
|
106
|
+
except Exception as err:
|
|
107
|
+
logger.warning(f"Failed to resolve OpenCode subagent: {err}")
|
|
108
|
+
else:
|
|
109
|
+
try:
|
|
110
|
+
subagent_def = load_claude_subagent(normalized)
|
|
111
|
+
if subagent_def:
|
|
112
|
+
subagent_name = subagent_def.name
|
|
113
|
+
subagent_model = subagent_def.model
|
|
114
|
+
subagent_reasoning_effort = subagent_def.reasoning_effort
|
|
115
|
+
except Exception as err:
|
|
116
|
+
logger.warning(f"Failed to resolve Claude subagent: {err}")
|
|
117
|
+
|
|
118
|
+
if subagent_name:
|
|
119
|
+
matched_prefix = parsed.name
|
|
120
|
+
subagent_message = parsed.message
|
|
121
|
+
|
|
122
|
+
if subagent_name and subagent_message:
|
|
123
|
+
message = subagent_message
|
|
124
|
+
if agent_name == "claude":
|
|
125
|
+
base_session_id = f"{base_session_id}:{subagent_name}"
|
|
126
|
+
composite_key = f"{base_session_id}:{working_path}"
|
|
127
|
+
|
|
128
|
+
ack_message_id = None
|
|
129
|
+
ack_mode = getattr(self.config, "ack_mode", "reaction")
|
|
130
|
+
ack_reaction_message_id = None
|
|
131
|
+
ack_reaction_emoji = None
|
|
132
|
+
|
|
133
|
+
if ack_mode == "message":
|
|
134
|
+
ack_context = self._get_target_context(context)
|
|
135
|
+
ack_text = self._get_ack_text(agent_name)
|
|
136
|
+
try:
|
|
137
|
+
ack_message_id = await self.im_client.send_message(
|
|
138
|
+
ack_context, ack_text
|
|
139
|
+
)
|
|
140
|
+
except Exception as ack_err:
|
|
141
|
+
logger.debug(f"Failed to send ack message: {ack_err}")
|
|
142
|
+
else:
|
|
143
|
+
# Default: add 👀 / :eyes: reaction to the user's message
|
|
144
|
+
try:
|
|
145
|
+
if context.message_id:
|
|
146
|
+
ack_reaction_message_id = context.message_id
|
|
147
|
+
ack_reaction_emoji = ":eyes:"
|
|
148
|
+
ok = await self.im_client.add_reaction(
|
|
149
|
+
context, ack_reaction_message_id, ack_reaction_emoji
|
|
150
|
+
)
|
|
151
|
+
if not ok:
|
|
152
|
+
logger.info(
|
|
153
|
+
"Ack reaction not applied (platform returned False)"
|
|
154
|
+
)
|
|
155
|
+
except Exception as ack_err:
|
|
156
|
+
logger.debug(f"Failed to add reaction ack: {ack_err}")
|
|
157
|
+
|
|
158
|
+
if subagent_name and context.message_id:
|
|
159
|
+
try:
|
|
160
|
+
reaction = ":robot_face:"
|
|
161
|
+
await self.im_client.add_reaction(
|
|
162
|
+
context,
|
|
163
|
+
context.message_id,
|
|
164
|
+
reaction,
|
|
165
|
+
)
|
|
166
|
+
except Exception as err:
|
|
167
|
+
logger.debug(f"Failed to add subagent reaction: {err}")
|
|
168
|
+
if ack_reaction_message_id and ack_reaction_emoji:
|
|
169
|
+
try:
|
|
170
|
+
await self.im_client.remove_reaction(
|
|
171
|
+
context, ack_reaction_message_id, ack_reaction_emoji
|
|
172
|
+
)
|
|
173
|
+
except Exception as err:
|
|
174
|
+
logger.debug(
|
|
175
|
+
f"Failed to remove reaction ack for subagent: {err}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
request = AgentRequest(
|
|
180
|
+
context=context,
|
|
181
|
+
message=message,
|
|
182
|
+
working_path=working_path,
|
|
183
|
+
base_session_id=base_session_id,
|
|
184
|
+
composite_session_id=composite_key,
|
|
185
|
+
settings_key=settings_key,
|
|
186
|
+
ack_message_id=ack_message_id,
|
|
187
|
+
subagent_name=subagent_name,
|
|
188
|
+
subagent_key=matched_prefix,
|
|
189
|
+
subagent_model=subagent_model,
|
|
190
|
+
subagent_reasoning_effort=subagent_reasoning_effort,
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
await self.controller.agent_service.handle_message(agent_name, request)
|
|
194
|
+
except KeyError:
|
|
195
|
+
await self._handle_missing_agent(context, agent_name)
|
|
196
|
+
finally:
|
|
197
|
+
if request.ack_message_id:
|
|
198
|
+
await self._delete_ack(context.channel_id, request)
|
|
199
|
+
elif ack_reaction_message_id and ack_reaction_emoji:
|
|
200
|
+
if not subagent_name:
|
|
201
|
+
try:
|
|
202
|
+
await self.im_client.remove_reaction(
|
|
203
|
+
context, ack_reaction_message_id, ack_reaction_emoji
|
|
204
|
+
)
|
|
205
|
+
except Exception as err:
|
|
206
|
+
logger.debug(f"Failed to remove reaction ack: {err}")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"Error processing user message: {e}", exc_info=True)
|
|
209
|
+
await self.im_client.send_message(
|
|
210
|
+
context, self.formatter.format_error(f"Error: {str(e)}")
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def handle_callback_query(self, context: MessageContext, callback_data: str):
|
|
214
|
+
"""Route callback queries to appropriate handlers"""
|
|
215
|
+
try:
|
|
216
|
+
logger.info(
|
|
217
|
+
f"handle_callback_query called with data: {callback_data} for user {context.user_id}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Import handlers to avoid circular dependency
|
|
221
|
+
from .settings_handler import SettingsHandler
|
|
222
|
+
from .command_handlers import CommandHandlers
|
|
223
|
+
|
|
224
|
+
settings_handler = SettingsHandler(self.controller)
|
|
225
|
+
command_handlers = CommandHandlers(self.controller)
|
|
226
|
+
|
|
227
|
+
# Route based on callback data
|
|
228
|
+
if callback_data.startswith("toggle_msg_"):
|
|
229
|
+
# Toggle message type visibility
|
|
230
|
+
msg_type = callback_data.replace("toggle_msg_", "")
|
|
231
|
+
await settings_handler.handle_toggle_message_type(context, msg_type)
|
|
232
|
+
elif callback_data.startswith("toggle_"):
|
|
233
|
+
# Legacy toggle handler (if any)
|
|
234
|
+
setting_type = callback_data.replace("toggle_", "")
|
|
235
|
+
handler = getattr(settings_handler, "handle_toggle_setting", None)
|
|
236
|
+
if handler:
|
|
237
|
+
await handler(context, setting_type)
|
|
238
|
+
|
|
239
|
+
elif callback_data == "info_msg_types":
|
|
240
|
+
logger.info(
|
|
241
|
+
f"Handling info_msg_types callback for user {context.user_id}"
|
|
242
|
+
)
|
|
243
|
+
await settings_handler.handle_info_message_types(context)
|
|
244
|
+
|
|
245
|
+
elif callback_data == "info_how_it_works":
|
|
246
|
+
await settings_handler.handle_info_how_it_works(context)
|
|
247
|
+
|
|
248
|
+
elif callback_data == "cmd_cwd":
|
|
249
|
+
await command_handlers.handle_cwd(context)
|
|
250
|
+
|
|
251
|
+
elif callback_data == "cmd_change_cwd":
|
|
252
|
+
await command_handlers.handle_change_cwd_modal(context)
|
|
253
|
+
|
|
254
|
+
elif callback_data == "cmd_clear":
|
|
255
|
+
await command_handlers.handle_clear(context)
|
|
256
|
+
|
|
257
|
+
elif callback_data == "cmd_settings":
|
|
258
|
+
await settings_handler.handle_settings(context)
|
|
259
|
+
|
|
260
|
+
elif callback_data == "cmd_routing":
|
|
261
|
+
await settings_handler.handle_routing(context)
|
|
262
|
+
|
|
263
|
+
elif (
|
|
264
|
+
callback_data.startswith("info_") and callback_data != "info_msg_types"
|
|
265
|
+
):
|
|
266
|
+
# Generic info handler
|
|
267
|
+
info_type = callback_data.replace("info_", "")
|
|
268
|
+
info_text = self.formatter.format_info_message(
|
|
269
|
+
title=f"Info: {info_type}",
|
|
270
|
+
emoji="ℹ️",
|
|
271
|
+
footer="This feature is coming soon!",
|
|
272
|
+
)
|
|
273
|
+
await self.im_client.send_message(context, info_text)
|
|
274
|
+
|
|
275
|
+
elif callback_data.startswith("opencode_question:"):
|
|
276
|
+
if not self.session_handler:
|
|
277
|
+
raise RuntimeError("Session handler not initialized")
|
|
278
|
+
|
|
279
|
+
base_session_id, working_path, composite_key = (
|
|
280
|
+
self.session_handler.get_session_info(context)
|
|
281
|
+
)
|
|
282
|
+
settings_key = self._get_settings_key(context)
|
|
283
|
+
request = AgentRequest(
|
|
284
|
+
context=context,
|
|
285
|
+
message=callback_data,
|
|
286
|
+
working_path=working_path,
|
|
287
|
+
base_session_id=base_session_id,
|
|
288
|
+
composite_session_id=composite_key,
|
|
289
|
+
settings_key=settings_key,
|
|
290
|
+
)
|
|
291
|
+
await self.controller.agent_service.handle_message("opencode", request)
|
|
292
|
+
|
|
293
|
+
else:
|
|
294
|
+
logger.warning(f"Unknown callback data: {callback_data}")
|
|
295
|
+
await self.im_client.send_message(
|
|
296
|
+
context,
|
|
297
|
+
self.formatter.format_warning(f"Unknown action: {callback_data}"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.error(f"Error handling callback query: {e}", exc_info=True)
|
|
302
|
+
await self.im_client.send_message(
|
|
303
|
+
context,
|
|
304
|
+
self.formatter.format_error(f"Error processing action: {str(e)}"),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def _handle_inline_stop(self, context: MessageContext) -> bool:
|
|
308
|
+
"""Route inline 'stop' messages to the active agent."""
|
|
309
|
+
try:
|
|
310
|
+
if not self.session_handler:
|
|
311
|
+
raise RuntimeError("Session handler not initialized")
|
|
312
|
+
|
|
313
|
+
base_session_id, working_path, composite_key = (
|
|
314
|
+
self.session_handler.get_session_info(context)
|
|
315
|
+
)
|
|
316
|
+
settings_key = self._get_settings_key(context)
|
|
317
|
+
agent_name = self.controller.resolve_agent_for_context(context)
|
|
318
|
+
request = AgentRequest(
|
|
319
|
+
context=context,
|
|
320
|
+
message="stop",
|
|
321
|
+
working_path=working_path,
|
|
322
|
+
base_session_id=base_session_id,
|
|
323
|
+
composite_session_id=composite_key,
|
|
324
|
+
settings_key=settings_key,
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
handled = await self.controller.agent_service.handle_stop(
|
|
328
|
+
agent_name, request
|
|
329
|
+
)
|
|
330
|
+
except KeyError:
|
|
331
|
+
await self._handle_missing_agent(context, agent_name)
|
|
332
|
+
return False
|
|
333
|
+
if not handled:
|
|
334
|
+
await self.im_client.send_message(
|
|
335
|
+
context, "ℹ️ No active session to stop."
|
|
336
|
+
)
|
|
337
|
+
return handled
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"Error handling inline stop: {e}", exc_info=True)
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
async def _handle_missing_agent(self, context: MessageContext, agent_name: str):
|
|
343
|
+
"""Notify user when a requested agent backend is unavailable."""
|
|
344
|
+
target = agent_name or self.controller.agent_service.default_agent
|
|
345
|
+
msg = (
|
|
346
|
+
f"❌ Agent `{target}` is not configured. "
|
|
347
|
+
"Make sure the Codex CLI is installed and environment variables are set "
|
|
348
|
+
"if this channel is routed to Codex."
|
|
349
|
+
)
|
|
350
|
+
await self.im_client.send_message(context, msg)
|
|
351
|
+
|
|
352
|
+
async def _delete_ack(self, channel_id: str, request: AgentRequest):
|
|
353
|
+
"""Delete acknowledgement message if it still exists."""
|
|
354
|
+
if request.ack_message_id and hasattr(self.im_client, "delete_message"):
|
|
355
|
+
try:
|
|
356
|
+
await self.im_client.delete_message(channel_id, request.ack_message_id)
|
|
357
|
+
except Exception as err:
|
|
358
|
+
logger.debug(f"Failed to delete ack message: {err}")
|
|
359
|
+
finally:
|
|
360
|
+
request.ack_message_id = None
|
|
361
|
+
|
|
362
|
+
def _get_ack_text(self, agent_name: str) -> str:
|
|
363
|
+
"""Unified acknowledgement text before agent processing."""
|
|
364
|
+
label = agent_name or self.controller.agent_service.default_agent
|
|
365
|
+
return f"📨 {label.capitalize()} received, processing..."
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Session management handlers for Claude SDK sessions"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Dict, Any, Tuple
|
|
6
|
+
from modules.im import MessageContext
|
|
7
|
+
from claude_code_sdk import ClaudeSDKClient, ClaudeCodeOptions
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionHandler:
|
|
13
|
+
"""Handles all session-related operations"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, controller):
|
|
16
|
+
"""Initialize with reference to main controller"""
|
|
17
|
+
self.controller = controller
|
|
18
|
+
self.config = controller.config
|
|
19
|
+
self.im_client = controller.im_client
|
|
20
|
+
self.session_manager = controller.session_manager
|
|
21
|
+
self.settings_manager = controller.settings_manager
|
|
22
|
+
self.formatter = controller.im_client.formatter
|
|
23
|
+
self.claude_sessions = controller.claude_sessions
|
|
24
|
+
self.receiver_tasks = controller.receiver_tasks
|
|
25
|
+
self.stored_session_mappings = controller.stored_session_mappings
|
|
26
|
+
|
|
27
|
+
def _get_settings_key(self, context: MessageContext) -> str:
|
|
28
|
+
"""Get settings key - delegate to controller"""
|
|
29
|
+
return self.controller._get_settings_key(context)
|
|
30
|
+
|
|
31
|
+
def get_base_session_id(self, context: MessageContext) -> str:
|
|
32
|
+
"""Get base session ID based on platform and context (without path)"""
|
|
33
|
+
# Slack only in V2; always use thread ID
|
|
34
|
+
return f"slack_{context.thread_id}"
|
|
35
|
+
|
|
36
|
+
def get_working_path(self, context: MessageContext) -> str:
|
|
37
|
+
"""Get working directory - delegate to controller's get_cwd"""
|
|
38
|
+
return self.controller.get_cwd(context)
|
|
39
|
+
|
|
40
|
+
def get_session_info(self, context: MessageContext) -> Tuple[str, str, str]:
|
|
41
|
+
"""Get session info: base_session_id, working_path, and composite_key"""
|
|
42
|
+
base_session_id = self.get_base_session_id(context)
|
|
43
|
+
working_path = self.get_working_path(context) # Pass context to get user's custom_cwd
|
|
44
|
+
# Create composite key for internal storage
|
|
45
|
+
composite_key = f"{base_session_id}:{working_path}"
|
|
46
|
+
return base_session_id, working_path, composite_key
|
|
47
|
+
|
|
48
|
+
async def get_or_create_claude_session(
|
|
49
|
+
self,
|
|
50
|
+
context: MessageContext,
|
|
51
|
+
subagent_name: Optional[str] = None,
|
|
52
|
+
subagent_model: Optional[str] = None,
|
|
53
|
+
subagent_reasoning_effort: Optional[str] = None,
|
|
54
|
+
) -> ClaudeSDKClient:
|
|
55
|
+
"""Get existing Claude session or create a new one"""
|
|
56
|
+
base_session_id, working_path, composite_key = self.get_session_info(context)
|
|
57
|
+
|
|
58
|
+
settings_key = self._get_settings_key(context)
|
|
59
|
+
stored_claude_session_id = self.settings_manager.get_claude_session_id(
|
|
60
|
+
settings_key, base_session_id
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if composite_key in self.claude_sessions and not subagent_name:
|
|
64
|
+
logger.info(f"Using existing Claude SDK client for {base_session_id} at {working_path}")
|
|
65
|
+
return self.claude_sessions[composite_key]
|
|
66
|
+
|
|
67
|
+
if subagent_name:
|
|
68
|
+
cached_base = f"{base_session_id}:{subagent_name}"
|
|
69
|
+
cached_key = f"{cached_base}:{working_path}"
|
|
70
|
+
cached_session_id = self.settings_manager.get_agent_session_id(
|
|
71
|
+
settings_key,
|
|
72
|
+
cached_base,
|
|
73
|
+
agent_name="claude",
|
|
74
|
+
)
|
|
75
|
+
if cached_key in self.claude_sessions:
|
|
76
|
+
logger.info(
|
|
77
|
+
"Using Claude subagent session for %s at %s", cached_base, working_path
|
|
78
|
+
)
|
|
79
|
+
return self.claude_sessions[cached_key]
|
|
80
|
+
if cached_session_id:
|
|
81
|
+
stored_claude_session_id = cached_session_id
|
|
82
|
+
composite_key = cached_key
|
|
83
|
+
base_session_id = cached_base
|
|
84
|
+
if composite_key in self.claude_sessions:
|
|
85
|
+
logger.info(
|
|
86
|
+
"Using Claude subagent session for %s at %s", cached_base, working_path
|
|
87
|
+
)
|
|
88
|
+
return self.claude_sessions[composite_key]
|
|
89
|
+
|
|
90
|
+
# Ensure working directory exists
|
|
91
|
+
if not os.path.exists(working_path):
|
|
92
|
+
try:
|
|
93
|
+
os.makedirs(working_path, exist_ok=True)
|
|
94
|
+
logger.info(f"Created working directory: {working_path}")
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"Failed to create working directory {working_path}: {e}")
|
|
97
|
+
working_path = os.getcwd()
|
|
98
|
+
|
|
99
|
+
# Create options for Claude client
|
|
100
|
+
extra_args = {}
|
|
101
|
+
if subagent_name:
|
|
102
|
+
extra_args["agent"] = subagent_name
|
|
103
|
+
if subagent_model:
|
|
104
|
+
extra_args["model"] = subagent_model
|
|
105
|
+
if subagent_reasoning_effort:
|
|
106
|
+
extra_args["reasoning-effort"] = subagent_reasoning_effort
|
|
107
|
+
|
|
108
|
+
options = ClaudeCodeOptions(
|
|
109
|
+
permission_mode=self.config.claude.permission_mode,
|
|
110
|
+
cwd=working_path,
|
|
111
|
+
system_prompt=self.config.claude.system_prompt,
|
|
112
|
+
resume=stored_claude_session_id if stored_claude_session_id else None,
|
|
113
|
+
extra_args=extra_args,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Log session creation details
|
|
117
|
+
logger.info(f"Creating Claude client for {base_session_id} at {working_path}")
|
|
118
|
+
logger.info(f" Working directory: {working_path}")
|
|
119
|
+
logger.info(f" Resume session ID: {stored_claude_session_id}")
|
|
120
|
+
logger.info(f" Options.resume: {options.resume}")
|
|
121
|
+
if subagent_name:
|
|
122
|
+
logger.info(f" Subagent: {subagent_name}")
|
|
123
|
+
|
|
124
|
+
# Log if we're resuming a session
|
|
125
|
+
if stored_claude_session_id:
|
|
126
|
+
logger.info(f"Attempting to resume Claude session {stored_claude_session_id}")
|
|
127
|
+
else:
|
|
128
|
+
logger.info(f"Creating new Claude session")
|
|
129
|
+
|
|
130
|
+
# Create new Claude client
|
|
131
|
+
client = ClaudeSDKClient(options=options)
|
|
132
|
+
|
|
133
|
+
# Log the actual options being used
|
|
134
|
+
logger.info("ClaudeCodeOptions details:")
|
|
135
|
+
logger.info(f" - permission_mode: {options.permission_mode}")
|
|
136
|
+
logger.info(f" - cwd: {options.cwd}")
|
|
137
|
+
logger.info(f" - system_prompt: {options.system_prompt}")
|
|
138
|
+
logger.info(f" - resume: {options.resume}")
|
|
139
|
+
logger.info(f" - continue_conversation: {options.continue_conversation}")
|
|
140
|
+
if subagent_name:
|
|
141
|
+
logger.info(f" - subagent: {subagent_name}")
|
|
142
|
+
|
|
143
|
+
# Connect the client
|
|
144
|
+
await client.connect()
|
|
145
|
+
|
|
146
|
+
self.claude_sessions[composite_key] = client
|
|
147
|
+
logger.info(f"Created new Claude SDK client for {base_session_id} at {working_path}")
|
|
148
|
+
|
|
149
|
+
return client
|
|
150
|
+
|
|
151
|
+
async def cleanup_session(self, composite_key: str):
|
|
152
|
+
"""Clean up a specific session by composite key"""
|
|
153
|
+
# Cancel receiver task if exists
|
|
154
|
+
if composite_key in self.receiver_tasks:
|
|
155
|
+
task = self.receiver_tasks[composite_key]
|
|
156
|
+
if not task.done():
|
|
157
|
+
task.cancel()
|
|
158
|
+
try:
|
|
159
|
+
await task
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
del self.receiver_tasks[composite_key]
|
|
163
|
+
logger.info(f"Cancelled receiver task for session {composite_key}")
|
|
164
|
+
|
|
165
|
+
# Cleanup Claude session
|
|
166
|
+
if composite_key in self.claude_sessions:
|
|
167
|
+
client = self.claude_sessions[composite_key]
|
|
168
|
+
try:
|
|
169
|
+
await client.disconnect()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error disconnecting Claude session {composite_key}: {e}")
|
|
172
|
+
del self.claude_sessions[composite_key]
|
|
173
|
+
logger.info(f"Cleaned up Claude session {composite_key}")
|
|
174
|
+
|
|
175
|
+
async def handle_session_error(self, composite_key: str, context: MessageContext, error: Exception):
|
|
176
|
+
"""Handle session-related errors"""
|
|
177
|
+
error_msg = str(error)
|
|
178
|
+
|
|
179
|
+
# Check for specific error types
|
|
180
|
+
if "read() called while another coroutine" in error_msg:
|
|
181
|
+
logger.error(f"Session {composite_key} has concurrent read error - cleaning up")
|
|
182
|
+
await self.cleanup_session(composite_key)
|
|
183
|
+
|
|
184
|
+
# Notify user and suggest retry
|
|
185
|
+
await self.im_client.send_message(
|
|
186
|
+
context,
|
|
187
|
+
self.formatter.format_error(
|
|
188
|
+
"Session error detected. Session has been reset. Please try your message again."
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
elif "Session is broken" in error_msg or "Connection closed" in error_msg or "Connection lost" in error_msg:
|
|
192
|
+
logger.error(f"Session {composite_key} is broken - cleaning up")
|
|
193
|
+
await self.cleanup_session(composite_key)
|
|
194
|
+
|
|
195
|
+
# Notify user
|
|
196
|
+
await self.im_client.send_message(
|
|
197
|
+
context,
|
|
198
|
+
self.formatter.format_error(
|
|
199
|
+
"Connection to Claude was lost. Please try your message again."
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
# Generic error handling
|
|
204
|
+
logger.error(f"Error in session {composite_key}: {error}")
|
|
205
|
+
await self.im_client.send_message(
|
|
206
|
+
context,
|
|
207
|
+
self.formatter.format_error(f"An error occurred: {error_msg}")
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def capture_session_id(self, base_session_id: str, claude_session_id: str, settings_key: str):
|
|
211
|
+
"""Capture and store Claude session ID mapping"""
|
|
212
|
+
# Persist to settings (settings_key is channel_id for Slack)
|
|
213
|
+
self.settings_manager.set_session_mapping(settings_key, base_session_id, claude_session_id)
|
|
214
|
+
|
|
215
|
+
logger.info(f"Captured Claude session_id: {claude_session_id} for {base_session_id}")
|
|
216
|
+
|
|
217
|
+
def restore_session_mappings(self):
|
|
218
|
+
"""Restore session mappings from settings on startup"""
|
|
219
|
+
logger.info("Initializing session mappings from saved settings...")
|
|
220
|
+
|
|
221
|
+
session_state = self.settings_manager.sessions_store.state.session_mappings
|
|
222
|
+
|
|
223
|
+
restored_count = 0
|
|
224
|
+
for user_id, agent_map in session_state.items():
|
|
225
|
+
claude_map = agent_map.get("claude", {}) if isinstance(agent_map, dict) else {}
|
|
226
|
+
for thread_id, claude_session_id in claude_map.items():
|
|
227
|
+
if isinstance(claude_session_id, str):
|
|
228
|
+
logger.info(
|
|
229
|
+
f" - {thread_id} -> {claude_session_id} (user {user_id})"
|
|
230
|
+
)
|
|
231
|
+
restored_count += 1
|
|
232
|
+
|
|
233
|
+
logger.info(f"Session restoration complete. Restored {restored_count} session mappings.")
|