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,368 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import signal
6
+ from asyncio.subprocess import Process
7
+ from typing import Dict, Optional, Tuple
8
+
9
+ from markdown_to_mrkdwn import SlackMarkdownConverter
10
+
11
+ from modules.agents.base import AgentRequest, BaseAgent
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ STREAM_BUFFER_LIMIT = 8 * 1024 * 1024 # 8MB cap for Codex stdout/stderr streams
16
+
17
+ class CodexAgent(BaseAgent):
18
+ """Codex CLI integration via codex exec JSON streaming mode."""
19
+
20
+ name = "codex"
21
+
22
+ def __init__(self, controller, codex_config):
23
+ super().__init__(controller)
24
+ self.codex_config = codex_config
25
+ self.active_processes: Dict[str, Tuple[Process, str]] = {}
26
+ self.base_process_index: Dict[str, str] = {}
27
+ self.composite_to_base: Dict[str, str] = {}
28
+ self._initialized_sessions: set[str] = set()
29
+ self._pending_assistant_messages: Dict[str, Tuple[str, Optional[str]]] = {}
30
+ self._slack_markdown_converter = (
31
+ SlackMarkdownConverter()
32
+ if getattr(self.controller.config, "platform", None) == "slack"
33
+ else None
34
+ )
35
+
36
+ async def handle_message(self, request: AgentRequest) -> None:
37
+ existing = self.base_process_index.get(request.base_session_id)
38
+ if existing and existing in self.active_processes:
39
+ await self.controller.emit_agent_message(
40
+ request.context,
41
+ "notify",
42
+ "⚠️ Codex is already processing a task in this thread. "
43
+ "Cancelling the previous run...",
44
+ )
45
+ await self._terminate_process(existing)
46
+ await self.controller.emit_agent_message(
47
+ request.context,
48
+ "notify",
49
+ "⏹ Previous Codex task cancelled. Starting the new request...",
50
+ )
51
+ resume_id = self.settings_manager.get_agent_session_id(
52
+ request.settings_key,
53
+ request.base_session_id,
54
+ agent_name=self.name,
55
+ )
56
+
57
+ if not os.path.exists(request.working_path):
58
+ os.makedirs(request.working_path, exist_ok=True)
59
+
60
+ cmd = self._build_command(request, resume_id)
61
+ try:
62
+ process = await asyncio.create_subprocess_exec(
63
+ *cmd,
64
+ stdout=asyncio.subprocess.PIPE,
65
+ stderr=asyncio.subprocess.PIPE,
66
+ cwd=request.working_path,
67
+ limit=STREAM_BUFFER_LIMIT,
68
+ **({"preexec_fn": os.setsid} if hasattr(os, "setsid") else {}),
69
+ )
70
+ except FileNotFoundError:
71
+ await self.controller.emit_agent_message(
72
+ request.context,
73
+ "notify",
74
+ "❌ Codex CLI not found. Please install it or set CODEX_CLI_PATH.",
75
+ )
76
+ return
77
+ except Exception as e:
78
+ logger.error(f"Failed to launch Codex CLI: {e}", exc_info=True)
79
+ await self.controller.emit_agent_message(
80
+ request.context, "notify", f"❌ Failed to start Codex CLI: {e}"
81
+ )
82
+ return
83
+
84
+ await self._delete_ack(request)
85
+
86
+ self.active_processes[request.composite_session_id] = (
87
+ process,
88
+ request.settings_key,
89
+ )
90
+ self.base_process_index[request.base_session_id] = request.composite_session_id
91
+ self.composite_to_base[request.composite_session_id] = request.base_session_id
92
+ logger.info(
93
+ f"Codex session {request.composite_session_id} started (pid={process.pid})"
94
+ )
95
+
96
+ stdout_task = asyncio.create_task(
97
+ self._consume_stdout(process, request)
98
+ )
99
+ stderr_task = asyncio.create_task(
100
+ self._consume_stderr(process, request)
101
+ )
102
+
103
+ try:
104
+ await process.wait()
105
+ await asyncio.gather(stdout_task, stderr_task)
106
+ finally:
107
+ self._unregister_process(request.composite_session_id)
108
+
109
+ if process.returncode != 0:
110
+ await self.controller.emit_agent_message(
111
+ request.context,
112
+ "notify",
113
+ "⚠️ Codex exited with a non-zero status. Review stderr for details.",
114
+ )
115
+
116
+ async def clear_sessions(self, settings_key: str) -> int:
117
+ self.settings_manager.clear_agent_sessions(settings_key, self.name)
118
+ # Terminate any active processes scoped to this settings key
119
+ terminated = 0
120
+ for key, (_, stored_key) in list(self.active_processes.items()):
121
+ if stored_key == settings_key:
122
+ await self._terminate_process(key)
123
+ terminated += 1
124
+ return terminated
125
+
126
+ async def handle_stop(self, request: AgentRequest) -> bool:
127
+ key = request.composite_session_id
128
+ if not await self._terminate_process(key):
129
+ key = self.base_process_index.get(request.base_session_id)
130
+ if not key or not await self._terminate_process(key):
131
+ return False
132
+ await self.controller.emit_agent_message(
133
+ request.context, "notify", "🛑 Terminated Codex execution."
134
+ )
135
+ logger.info(f"Codex session {key} terminated via /stop")
136
+ return True
137
+
138
+ def _unregister_process(self, composite_key: str):
139
+ self.active_processes.pop(composite_key, None)
140
+ self._pending_assistant_messages.pop(composite_key, None)
141
+ base_id = self.composite_to_base.pop(composite_key, None)
142
+ if base_id and self.base_process_index.get(base_id) == composite_key:
143
+ self.base_process_index.pop(base_id, None)
144
+
145
+ async def _terminate_process(self, composite_key: str) -> bool:
146
+ entry = self.active_processes.get(composite_key)
147
+ if not entry:
148
+ return False
149
+
150
+ proc, _ = entry
151
+ try:
152
+ if hasattr(os, "getpgid"):
153
+ try:
154
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
155
+ except ProcessLookupError:
156
+ pass
157
+ else:
158
+ proc.kill()
159
+ await proc.wait()
160
+ except ProcessLookupError:
161
+ pass
162
+
163
+ self._unregister_process(composite_key)
164
+ return True
165
+
166
+ def _build_command(self, request: AgentRequest, resume_id: Optional[str]) -> list:
167
+ cmd = [self.codex_config.binary, "exec", "--json"]
168
+ cmd += ["--dangerously-bypass-approvals-and-sandbox"]
169
+ cmd += ["--skip-git-repo-check"]
170
+
171
+ if self.codex_config.default_model:
172
+ cmd += ["--model", self.codex_config.default_model]
173
+
174
+ cmd += ["--cd", request.working_path]
175
+ cmd += self.codex_config.extra_args
176
+
177
+ if resume_id:
178
+ cmd += ["resume", resume_id]
179
+
180
+ cmd.append(request.message)
181
+
182
+ logger.info(f"Executing Codex command: {' '.join(cmd[:-1])} <prompt>")
183
+ return cmd
184
+
185
+ async def _consume_stdout(self, process: Process, request: AgentRequest):
186
+ assert process.stdout is not None
187
+ try:
188
+ while True:
189
+ try:
190
+ line = await process.stdout.readline()
191
+ except (asyncio.LimitOverrunError, ValueError) as err:
192
+ await self._notify_stream_error(
193
+ request, f"Codex output too long; stream decode failed: {err}"
194
+ )
195
+ logger.exception("Codex stdout exceeded buffer limit")
196
+ break
197
+ if not line:
198
+ break
199
+ line = line.decode().strip()
200
+ if not line:
201
+ continue
202
+ try:
203
+ event = json.loads(line)
204
+ except json.JSONDecodeError:
205
+ logger.debug(f"Codex emitted non-JSON line: {line}")
206
+ continue
207
+ await self._handle_event(event, request)
208
+ except Exception as err:
209
+ await self._notify_stream_error(
210
+ request, f"Codex stdout 读取异常:{err}"
211
+ )
212
+ logger.exception("Unexpected Codex stdout error")
213
+
214
+ async def _consume_stderr(self, process: Process, request: AgentRequest):
215
+ assert process.stderr is not None
216
+ buffer = []
217
+ while True:
218
+ line = await process.stderr.readline()
219
+ if not line:
220
+ break
221
+ decoded = line.decode(errors="ignore").rstrip()
222
+ buffer.append(decoded)
223
+ logger.debug(f"Codex stderr: {decoded}")
224
+
225
+ if buffer:
226
+ joined = "\n".join(buffer[-10:])
227
+ await self.controller.emit_agent_message(
228
+ request.context,
229
+ "notify",
230
+ f"❗️ Codex stderr:\n```stderr\n{joined}\n```",
231
+ parse_mode="markdown",
232
+ )
233
+
234
+ async def _handle_event(
235
+ self, event: Dict, request: AgentRequest
236
+ ):
237
+ event_type = event.get("type")
238
+
239
+ if event_type == "thread.started":
240
+ thread_id = event.get("thread_id")
241
+ if thread_id:
242
+ self.settings_manager.set_agent_session_mapping(
243
+ request.settings_key,
244
+ self.name,
245
+ request.base_session_id,
246
+ thread_id,
247
+ )
248
+ session_key = request.composite_session_id
249
+ if session_key not in self._initialized_sessions:
250
+ self._initialized_sessions.add(session_key)
251
+ system_text = self.im_client.formatter.format_system_message(
252
+ request.working_path, "init", thread_id
253
+ )
254
+ await self.controller.emit_agent_message(
255
+ request.context,
256
+ "system",
257
+ system_text,
258
+ parse_mode="markdown",
259
+ )
260
+ return
261
+
262
+ if event_type == "item.completed":
263
+ details = event.get("item", {})
264
+ item_type = details.get("type")
265
+
266
+ if item_type == "agent_message":
267
+ text = details.get("text", "")
268
+ if text:
269
+ session_key = request.composite_session_id
270
+ pending = self._pending_assistant_messages.get(session_key)
271
+ if pending:
272
+ pending_text, pending_parse_mode = pending
273
+ await self.controller.emit_agent_message(
274
+ request.context,
275
+ "assistant",
276
+ pending_text,
277
+ parse_mode=pending_parse_mode or "markdown",
278
+ )
279
+
280
+ self._pending_assistant_messages[session_key] = self._prepare_last_message_payload(text)
281
+ elif item_type == "command_execution":
282
+ command = details.get("command")
283
+ status = details.get("status")
284
+ if command:
285
+ toolcall = self.im_client.formatter.format_toolcall(
286
+ "bash",
287
+ {"command": command, "status": status},
288
+ )
289
+ await self.controller.emit_agent_message(
290
+ request.context,
291
+ "toolcall",
292
+ toolcall,
293
+ parse_mode="markdown",
294
+ )
295
+ elif item_type == "reasoning":
296
+ text = details.get("text", "")
297
+ if text:
298
+ await self.controller.emit_agent_message(
299
+ request.context,
300
+ "assistant",
301
+ f"_🧠 {text}_",
302
+ parse_mode="markdown",
303
+ )
304
+ return
305
+
306
+ if event_type == "error":
307
+ message = event.get("message", "Unknown error")
308
+ await self.controller.emit_agent_message(
309
+ request.context, "notify", f"❌ Codex error: {message}"
310
+ )
311
+ return
312
+
313
+ if event_type == "turn.failed":
314
+ error = event.get("error", {}).get("message", "Turn failed.")
315
+ await self.controller.emit_agent_message(
316
+ request.context, "notify", f"⚠️ Codex turn failed: {error}"
317
+ )
318
+ self._pending_assistant_messages.pop(request.composite_session_id, None)
319
+ return
320
+
321
+ if event_type == "turn.completed":
322
+ pending = self._pending_assistant_messages.pop(
323
+ request.composite_session_id, None
324
+ )
325
+ if pending:
326
+ pending_text, pending_parse_mode = pending
327
+ await self.emit_result_message(
328
+ request.context,
329
+ pending_text,
330
+ subtype="success",
331
+ started_at=request.started_at,
332
+ parse_mode=pending_parse_mode or "markdown",
333
+ )
334
+ else:
335
+ await self.emit_result_message(
336
+ request.context,
337
+ None,
338
+ subtype="success",
339
+ started_at=request.started_at,
340
+ parse_mode="markdown",
341
+ )
342
+ return
343
+
344
+ async def _delete_ack(self, request: AgentRequest):
345
+ ack_id = request.ack_message_id
346
+ if ack_id and hasattr(self.im_client, "delete_message"):
347
+ try:
348
+ await self.im_client.delete_message(
349
+ request.context.channel_id, ack_id
350
+ )
351
+ except Exception as err:
352
+ logger.debug(f"Could not delete ack message: {err}")
353
+ finally:
354
+ request.ack_message_id = None
355
+
356
+ def _prepare_last_message_payload(
357
+ self, text: str
358
+ ) -> Tuple[str, Optional[str]]:
359
+ """Prepare cached assistant text for reuse in result messages."""
360
+ return text, "markdown"
361
+
362
+ async def _notify_stream_error(self, request: AgentRequest, message: str) -> None:
363
+ """Emit a notify message when Codex stdout handling fails."""
364
+ await self.controller.emit_agent_message(
365
+ request.context,
366
+ "notify",
367
+ f"⚠️ {message}\n请查看 `~/.vibe_remote/logs/vibe_remote.log` 获取更多细节。",
368
+ )