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,344 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from typing import Callable, Optional
5
+
6
+ from claude_code_sdk import TextBlock, ToolUseBlock
7
+
8
+ from modules.agents.base import AgentRequest, BaseAgent
9
+ from modules.im import MessageContext
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ClaudeAgent(BaseAgent):
15
+ """Existing Claude Code integration extracted into an agent backend."""
16
+
17
+ name = "claude"
18
+
19
+ def __init__(self, controller):
20
+ super().__init__(controller)
21
+ self.session_handler = controller.session_handler
22
+ self.session_manager = controller.session_manager
23
+ self.receiver_tasks = controller.receiver_tasks
24
+ self.claude_sessions = controller.claude_sessions
25
+ self.claude_client = controller.claude_client
26
+ self._last_assistant_text: dict[str, str] = {}
27
+ self._pending_assistant_message: dict[str, str] = {}
28
+
29
+ async def handle_message(self, request: AgentRequest) -> None:
30
+ context = request.context
31
+
32
+ try:
33
+ client = await self.session_handler.get_or_create_claude_session(
34
+ context,
35
+ subagent_name=request.subagent_name,
36
+ subagent_model=request.subagent_model,
37
+ subagent_reasoning_effort=request.subagent_reasoning_effort,
38
+ )
39
+
40
+ await client.query(
41
+ request.message, session_id=request.composite_session_id
42
+ )
43
+ logger.info(
44
+ f"Sent message to Claude for session {request.composite_session_id}"
45
+ )
46
+
47
+ await self._delete_ack(context, request)
48
+
49
+ if (
50
+ request.composite_session_id not in self.receiver_tasks
51
+ or self.receiver_tasks[request.composite_session_id].done()
52
+ ):
53
+ self.receiver_tasks[request.composite_session_id] = asyncio.create_task(
54
+ self._receive_messages(
55
+ client, request.base_session_id, request.working_path, context
56
+ )
57
+ )
58
+ except Exception as e:
59
+ logger.error(f"Error processing Claude message: {e}", exc_info=True)
60
+ await self.session_handler.handle_session_error(
61
+ request.composite_session_id, context, e
62
+ )
63
+ finally:
64
+ await self._delete_ack(context, request)
65
+
66
+ async def clear_sessions(self, settings_key: str) -> int:
67
+ """Clear Claude sessions scoped to the provided settings key."""
68
+ agent_map = self.settings_manager.sessions_store.get_agent_map(
69
+ settings_key, self.name
70
+ )
71
+ session_bases_to_clear = set(agent_map.keys())
72
+
73
+ self.settings_manager.clear_agent_sessions(settings_key, self.name)
74
+
75
+ sessions_to_clear = []
76
+ for session_key in list(self.claude_sessions.keys()):
77
+ base_part = session_key.split(":")[0] if ":" in session_key else session_key
78
+ if base_part in session_bases_to_clear:
79
+ sessions_to_clear.append(session_key)
80
+
81
+ for session_key in sessions_to_clear:
82
+ try:
83
+ client = self.claude_sessions[session_key]
84
+ if hasattr(client, "close"):
85
+ await client.close()
86
+ except Exception as e:
87
+ logger.warning(f"Error closing Claude session {session_key}: {e}")
88
+ finally:
89
+ self.claude_sessions.pop(session_key, None)
90
+
91
+ # Legacy session manager cleanup (best-effort)
92
+ await self.session_manager.clear_session(settings_key)
93
+
94
+ return len(sessions_to_clear) or len(session_bases_to_clear)
95
+
96
+ async def handle_stop(self, request: AgentRequest) -> bool:
97
+ composite_key = request.composite_session_id
98
+ if composite_key not in self.claude_sessions:
99
+ return False
100
+
101
+ client = self.claude_sessions[composite_key]
102
+ await self.controller.emit_agent_message(
103
+ request.context, "notify", "🛑 Interrupting Claude session..."
104
+ )
105
+ try:
106
+ if hasattr(client, "interrupt"):
107
+ await client.interrupt()
108
+ return True
109
+ else:
110
+ await self.controller.emit_agent_message(
111
+ request.context,
112
+ "notify",
113
+ "⚠️ This Claude session cannot be interrupted; consider /clear.",
114
+ )
115
+ return False
116
+ except Exception as err:
117
+ logger.error(f"Failed to interrupt Claude session {composite_key}: {err}")
118
+ await self.controller.emit_agent_message(
119
+ request.context,
120
+ "notify",
121
+ "⚠️ Failed to interrupt Claude session. Please try /clear.",
122
+ )
123
+ return False
124
+
125
+ async def _receive_messages(
126
+ self,
127
+ client,
128
+ base_session_id: str,
129
+ working_path: str,
130
+ context: MessageContext,
131
+ ):
132
+ """Receive messages from Claude SDK client."""
133
+ try:
134
+ settings_key = self.controller._get_settings_key(context)
135
+ composite_key = f"{base_session_id}:{working_path}"
136
+ async for message in client.receive_messages():
137
+ try:
138
+ claude_session_id = self._maybe_capture_session_id(
139
+ message, base_session_id, settings_key
140
+ )
141
+ if claude_session_id:
142
+ logger.info(
143
+ f"Captured Claude session id {claude_session_id} for {base_session_id}"
144
+ )
145
+
146
+ if self.claude_client._is_skip_message(message):
147
+ continue
148
+
149
+ message_type = self._detect_message_type(message)
150
+ formatter = self.im_client.formatter
151
+
152
+ if message_type == "assistant":
153
+ toolcalls = []
154
+ text_parts = []
155
+ for block in getattr(message, "content", []) or []:
156
+ if isinstance(block, ToolUseBlock):
157
+ toolcalls.append(
158
+ formatter.format_toolcall(
159
+ block.name,
160
+ block.input,
161
+ get_relative_path=lambda path: self.get_relative_path(
162
+ path, context
163
+ ),
164
+ )
165
+ )
166
+ elif isinstance(block, TextBlock):
167
+ text = block.text.strip() if block.text else ""
168
+ if text:
169
+ text_parts.append(text)
170
+
171
+ assistant_text = self._extract_text_blocks(message)
172
+ if assistant_text:
173
+ self._last_assistant_text[composite_key] = assistant_text
174
+
175
+ pending = self._pending_assistant_message.pop(composite_key, None)
176
+ if pending:
177
+ await self.controller.emit_agent_message(
178
+ context,
179
+ "assistant",
180
+ pending,
181
+ parse_mode="markdown",
182
+ )
183
+
184
+ for toolcall in toolcalls:
185
+ await self.controller.emit_agent_message(
186
+ context,
187
+ "toolcall",
188
+ toolcall,
189
+ parse_mode="markdown",
190
+ )
191
+
192
+ if text_parts:
193
+ formatted_assistant = formatter.format_assistant_message(
194
+ text_parts
195
+ )
196
+ self._pending_assistant_message[composite_key] = formatted_assistant
197
+ continue
198
+
199
+ if message_type == "system":
200
+ formatted_message = self.claude_client.format_message(
201
+ message,
202
+ get_relative_path=lambda path: self.get_relative_path(
203
+ path, context
204
+ ),
205
+ )
206
+ if formatted_message and formatted_message.strip():
207
+ await self.controller.emit_agent_message(
208
+ context,
209
+ "system",
210
+ formatted_message,
211
+ parse_mode="markdown",
212
+ )
213
+ continue
214
+
215
+ if message_type == "result":
216
+ pending = self._pending_assistant_message.pop(composite_key, None)
217
+ result_text = getattr(message, "result", None)
218
+ used_fallback = False
219
+ if not result_text:
220
+ fallback = self._last_assistant_text.get(composite_key)
221
+ if fallback:
222
+ result_text = fallback
223
+ used_fallback = True
224
+
225
+ if pending and not used_fallback:
226
+ await self.controller.emit_agent_message(
227
+ context,
228
+ "assistant",
229
+ pending,
230
+ parse_mode="markdown",
231
+ )
232
+
233
+ await self.emit_result_message(
234
+ context,
235
+ result_text,
236
+ subtype=getattr(message, "subtype", "") or "",
237
+ duration_ms=getattr(message, "duration_ms", 0),
238
+ parse_mode="markdown",
239
+ )
240
+
241
+ self._last_assistant_text.pop(composite_key, None)
242
+ session = await self.session_manager.get_or_create_session(
243
+ context.user_id, context.channel_id
244
+ )
245
+ if session:
246
+ session.session_active[
247
+ f"{base_session_id}:{working_path}"
248
+ ] = False
249
+ continue
250
+
251
+ # Ignore UserMessage/tool results; toolcalls are emitted from ToolUseBlock.
252
+ continue
253
+ except Exception as e:
254
+ logger.error(
255
+ f"Error processing message from Claude: {e}", exc_info=True
256
+ )
257
+ continue
258
+ except Exception as e:
259
+ composite_key = f"{base_session_id}:{working_path}"
260
+ logger.error(
261
+ f"Error in Claude receiver for session {composite_key}: {e}",
262
+ exc_info=True,
263
+ )
264
+ await self.session_handler.handle_session_error(composite_key, context, e)
265
+
266
+ async def _delete_ack(self, context: MessageContext, request: AgentRequest):
267
+ ack_id = request.ack_message_id
268
+ if ack_id and hasattr(self.im_client, "delete_message"):
269
+ try:
270
+ await self.im_client.delete_message(context.channel_id, ack_id)
271
+ except Exception as err:
272
+ logger.debug(f"Could not delete ack message: {err}")
273
+ finally:
274
+ request.ack_message_id = None
275
+
276
+ def get_relative_path(
277
+ self, abs_path: str, context: Optional[MessageContext] = None
278
+ ) -> str:
279
+ """Convert absolute path to relative path from working directory."""
280
+ try:
281
+ cwd = self.session_handler.get_working_path(context)
282
+ abs_path = os.path.abspath(os.path.expanduser(abs_path))
283
+ rel_path = os.path.relpath(abs_path, cwd)
284
+ if rel_path.startswith("../.."):
285
+ return abs_path
286
+ return rel_path
287
+ except Exception:
288
+ return abs_path
289
+
290
+ def _get_target_context(self, context: MessageContext) -> MessageContext:
291
+ """Return context for sending messages (respect Slack thread replies)."""
292
+ if self.im_client.should_use_thread_for_reply() and context.thread_id:
293
+ return MessageContext(
294
+ user_id=context.user_id,
295
+ channel_id=context.channel_id,
296
+ thread_id=context.thread_id,
297
+ message_id=context.message_id,
298
+ platform_specific=context.platform_specific,
299
+ )
300
+ return context
301
+
302
+ def _maybe_capture_session_id(
303
+ self,
304
+ message,
305
+ base_session_id: str,
306
+ settings_key: str,
307
+ ) -> Optional[str]:
308
+ """Capture session id from system init messages."""
309
+ if (
310
+ hasattr(message, "__class__")
311
+ and message.__class__.__name__ == "SystemMessage"
312
+ and getattr(message, "subtype", None) == "init"
313
+ and getattr(message, "data", None)
314
+ ):
315
+ session_id = message.data.get("session_id")
316
+ if session_id:
317
+ self.session_handler.capture_session_id(
318
+ base_session_id, session_id, settings_key
319
+ )
320
+ return session_id
321
+ return None
322
+
323
+ def _extract_text_blocks(self, message) -> str:
324
+ """Extract text-only content blocks for result fallbacks."""
325
+ parts = []
326
+ for block in getattr(message, "content", []) or []:
327
+ if isinstance(block, TextBlock):
328
+ text = block.text.strip() if block.text else ""
329
+ if text:
330
+ parts.append(self.claude_client.formatter.escape_special_chars(text))
331
+ return "\n\n".join(parts).strip()
332
+
333
+ def _detect_message_type(self, message) -> Optional[str]:
334
+ """Infer message type name from Claude SDK class."""
335
+ if not hasattr(message, "__class__"):
336
+ return None
337
+ class_name = message.__class__.__name__
338
+ mapping = {
339
+ "SystemMessage": "system",
340
+ "UserMessage": "user",
341
+ "AssistantMessage": "assistant",
342
+ "ResultMessage": "result",
343
+ }
344
+ return mapping.get(class_name)