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
@@ -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.")