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,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)
|