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,2155 @@
1
+ """OpenCode Server API integration as an agent backend."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import time
11
+ from asyncio.subprocess import Process
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import aiohttp
16
+
17
+ from modules.agents.base import AgentRequest, BaseAgent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DEFAULT_OPENCODE_PORT = 4096
22
+ DEFAULT_OPENCODE_HOST = "127.0.0.1"
23
+ SERVER_START_TIMEOUT = 15
24
+
25
+
26
+ _REASONING_FALLBACK_OPTIONS = [
27
+ {"value": "low", "label": "Low"},
28
+ {"value": "medium", "label": "Medium"},
29
+ {"value": "high", "label": "High"},
30
+ ]
31
+
32
+ _REASONING_VARIANT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
33
+
34
+ _REASONING_VARIANT_LABELS = {
35
+ "none": "None",
36
+ "minimal": "Minimal",
37
+ "low": "Low",
38
+ "medium": "Medium",
39
+ "high": "High",
40
+ "xhigh": "Extra High",
41
+ "max": "Max",
42
+ }
43
+
44
+
45
+ def _parse_model_key(model_key: Optional[str]) -> tuple[str, str]:
46
+ if not model_key:
47
+ return "", ""
48
+ parts = model_key.split("/", 1)
49
+ if len(parts) != 2:
50
+ return "", ""
51
+ return parts[0], parts[1]
52
+
53
+
54
+ def _find_model_variants(opencode_models: dict, target_model: Optional[str]) -> Dict[str, Any]:
55
+ target_provider, target_model_id = _parse_model_key(target_model)
56
+ if not target_provider or not target_model_id or not isinstance(opencode_models, dict):
57
+ return {}
58
+ providers_data = opencode_models.get("providers", [])
59
+ for provider in providers_data:
60
+ provider_id = provider.get("id") or provider.get("provider_id") or provider.get("name")
61
+ if provider_id != target_provider:
62
+ continue
63
+
64
+ models = provider.get("models", {})
65
+ model_info: Optional[dict] = None
66
+ if isinstance(models, dict):
67
+ candidate = models.get(target_model_id)
68
+ if isinstance(candidate, dict):
69
+ model_info = candidate
70
+ elif isinstance(models, list):
71
+ for entry in models:
72
+ if isinstance(entry, dict) and entry.get("id") == target_model_id:
73
+ model_info = entry
74
+ break
75
+
76
+ if isinstance(model_info, dict):
77
+ variants = model_info.get("variants", {})
78
+ if isinstance(variants, dict):
79
+ return variants
80
+ break
81
+ return {}
82
+
83
+
84
+ def _build_reasoning_options_from_variants(variants: Dict[str, Any]) -> List[Dict[str, str]]:
85
+ sorted_variants = sorted(
86
+ variants.keys(),
87
+ key=lambda variant: (
88
+ _REASONING_VARIANT_ORDER.index(variant)
89
+ if variant in _REASONING_VARIANT_ORDER
90
+ else len(_REASONING_VARIANT_ORDER),
91
+ variant,
92
+ ),
93
+ )
94
+ return [
95
+ {
96
+ "value": variant_key,
97
+ "label": _REASONING_VARIANT_LABELS.get(
98
+ variant_key, variant_key.capitalize()
99
+ ),
100
+ }
101
+ for variant_key in sorted_variants
102
+ ]
103
+
104
+
105
+ def build_reasoning_effort_options(
106
+ opencode_models: dict,
107
+ target_model: Optional[str],
108
+ ) -> List[Dict[str, str]]:
109
+ """Build reasoning effort options from OpenCode model metadata."""
110
+ options = [{"value": "__default__", "label": "(Default)"}]
111
+ variants = _find_model_variants(opencode_models, target_model)
112
+ if variants:
113
+ options.extend(_build_reasoning_options_from_variants(variants))
114
+ return options
115
+ options.extend(_REASONING_FALLBACK_OPTIONS)
116
+ return options
117
+
118
+
119
+ class OpenCodeServerManager:
120
+ """Manages a singleton OpenCode server process shared across all working directories."""
121
+
122
+ _instance: Optional["OpenCodeServerManager"] = None
123
+ _class_lock: asyncio.Lock = asyncio.Lock()
124
+
125
+ def __init__(
126
+ self,
127
+ binary: str = "opencode",
128
+ port: int = DEFAULT_OPENCODE_PORT,
129
+ request_timeout_seconds: int = 60,
130
+ ):
131
+ self.binary = binary
132
+ self.port = port
133
+ self.request_timeout_seconds = request_timeout_seconds
134
+ self.host = DEFAULT_OPENCODE_HOST
135
+ self._process: Optional[Process] = None
136
+ self._base_url: Optional[str] = None
137
+ self._http_session: Optional[aiohttp.ClientSession] = None
138
+ self._http_session_loop: Optional[asyncio.AbstractEventLoop] = None
139
+ self._lock = asyncio.Lock()
140
+ self._pid_file = (
141
+ Path(__file__).resolve().parents[2] / "logs" / "opencode_server.json"
142
+ )
143
+
144
+ @classmethod
145
+ async def get_instance(
146
+ cls,
147
+ binary: str = "opencode",
148
+ port: int = DEFAULT_OPENCODE_PORT,
149
+ request_timeout_seconds: int = 60,
150
+ ) -> "OpenCodeServerManager":
151
+ async with cls._class_lock:
152
+ if cls._instance is None:
153
+ cls._instance = cls(
154
+ binary=binary,
155
+ port=port,
156
+ request_timeout_seconds=request_timeout_seconds,
157
+ )
158
+ elif (
159
+ cls._instance.binary != binary
160
+ or cls._instance.port != port
161
+ or cls._instance.request_timeout_seconds != request_timeout_seconds
162
+ ):
163
+ logger.warning(
164
+ "OpenCodeServerManager already initialized with "
165
+ f"binary={cls._instance.binary}, port={cls._instance.port}, "
166
+ f"request_timeout_seconds={cls._instance.request_timeout_seconds}; "
167
+ f"ignoring new params binary={binary}, port={port}, "
168
+ f"request_timeout_seconds={request_timeout_seconds}"
169
+ )
170
+ return cls._instance
171
+
172
+ @property
173
+ def base_url(self) -> str:
174
+ if self._base_url:
175
+ return self._base_url
176
+ return f"http://{self.host}:{self.port}"
177
+
178
+ async def _get_http_session(self) -> aiohttp.ClientSession:
179
+ if self._http_session is None or self._http_session.closed:
180
+ total_timeout: Optional[int] = (
181
+ None
182
+ if self.request_timeout_seconds <= 0
183
+ else self.request_timeout_seconds
184
+ )
185
+ self._http_session = aiohttp.ClientSession(
186
+ timeout=aiohttp.ClientTimeout(total=total_timeout)
187
+ )
188
+ self._http_session_loop = asyncio.get_running_loop()
189
+ return self._http_session
190
+
191
+ def _read_pid_file(self) -> Optional[Dict[str, Any]]:
192
+ try:
193
+ raw = self._pid_file.read_text()
194
+ except FileNotFoundError:
195
+ return None
196
+ except Exception as e:
197
+ logger.debug(f"Failed to read OpenCode pid file: {e}")
198
+ return None
199
+
200
+ try:
201
+ data = json.loads(raw)
202
+ except Exception as e:
203
+ logger.debug(f"Failed to parse OpenCode pid file: {e}")
204
+ return None
205
+
206
+ return data if isinstance(data, dict) else None
207
+
208
+ def _write_pid_file(self, pid: int) -> None:
209
+ try:
210
+ self._pid_file.parent.mkdir(parents=True, exist_ok=True)
211
+ payload = {
212
+ "pid": pid,
213
+ "port": self.port,
214
+ "host": self.host,
215
+ "started_at": time.time(),
216
+ }
217
+ self._pid_file.write_text(json.dumps(payload))
218
+ except Exception as e:
219
+ logger.debug(f"Failed to write OpenCode pid file: {e}")
220
+
221
+ def _clear_pid_file(self) -> None:
222
+ try:
223
+ if self._pid_file.exists():
224
+ self._pid_file.unlink()
225
+ except Exception as e:
226
+ logger.debug(f"Failed to clear OpenCode pid file: {e}")
227
+
228
+ @staticmethod
229
+ def _pid_exists(pid: int) -> bool:
230
+ if not isinstance(pid, int) or pid <= 0:
231
+ return False
232
+ try:
233
+ os.kill(pid, 0)
234
+ return True
235
+ except ProcessLookupError:
236
+ return False
237
+ except PermissionError:
238
+ return True
239
+
240
+ @staticmethod
241
+ def _get_pid_command(pid: int) -> Optional[str]:
242
+ try:
243
+ result = subprocess.run(
244
+ ["ps", "-p", str(pid), "-o", "command="],
245
+ capture_output=True,
246
+ text=True,
247
+ check=False,
248
+ )
249
+ except Exception:
250
+ return None
251
+ cmd = (result.stdout or "").strip()
252
+ return cmd or None
253
+
254
+ @staticmethod
255
+ def _is_opencode_serve_cmd(command: str, port: int) -> bool:
256
+ if not command:
257
+ return False
258
+ return "opencode" in command and " serve" in command and f"--port={port}" in command
259
+
260
+ def _is_port_available(self) -> bool:
261
+ try:
262
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
263
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
264
+ sock.bind((self.host, self.port))
265
+ return True
266
+ except OSError:
267
+ return False
268
+
269
+ @staticmethod
270
+ def _find_opencode_serve_pids(port: int) -> List[int]:
271
+ try:
272
+ result = subprocess.run(
273
+ ["ps", "-ax", "-o", "pid=,command="],
274
+ capture_output=True,
275
+ text=True,
276
+ check=False,
277
+ )
278
+ except Exception:
279
+ return []
280
+
281
+ needle = f"--port={port}"
282
+ pids: List[int] = []
283
+ for line in (result.stdout or "").splitlines():
284
+ line = line.strip()
285
+ if not line:
286
+ continue
287
+ parts = line.split(None, 1)
288
+ if len(parts) != 2:
289
+ continue
290
+ pid_str, cmd = parts
291
+ if "opencode" in cmd and " serve" in cmd and needle in cmd:
292
+ try:
293
+ pids.append(int(pid_str))
294
+ except ValueError:
295
+ continue
296
+ return pids
297
+
298
+ async def _terminate_pid(self, pid: int, reason: str) -> None:
299
+ logger.info(f"Stopping OpenCode server pid={pid} ({reason})")
300
+ try:
301
+ os.kill(pid, signal.SIGTERM)
302
+ except ProcessLookupError:
303
+ return
304
+ except Exception as e:
305
+ logger.debug(f"Failed to terminate OpenCode server pid={pid}: {e}")
306
+ return
307
+
308
+ start_time = time.monotonic()
309
+ while time.monotonic() - start_time < 5:
310
+ if not self._pid_exists(pid):
311
+ return
312
+ await asyncio.sleep(0.25)
313
+
314
+ try:
315
+ os.kill(pid, signal.SIGKILL)
316
+ except Exception:
317
+ pass
318
+
319
+ async def _cleanup_orphaned_managed_server(self) -> None:
320
+ info = self._read_pid_file()
321
+ if not info:
322
+ return
323
+
324
+ pid = info.get("pid")
325
+ port = info.get("port")
326
+ if not isinstance(pid, int) or port != self.port:
327
+ self._clear_pid_file()
328
+ return
329
+
330
+ if self._process and self._process.returncode is None and self._process.pid == pid:
331
+ return
332
+
333
+ # Check if the server is healthy before deciding to kill it.
334
+ # If it's healthy, we should adopt it rather than kill it.
335
+ if await self._is_healthy():
336
+ # Update PID file to reflect the actual running process.
337
+ # The PID in the file may be stale if OpenCode was restarted externally.
338
+ actual_pids = self._find_opencode_serve_pids(self.port)
339
+ if actual_pids:
340
+ actual_pid = actual_pids[0]
341
+ if actual_pid != pid:
342
+ logger.info(
343
+ f"Adopting healthy OpenCode server (updating stale PID {pid} -> {actual_pid})"
344
+ )
345
+ self._write_pid_file(actual_pid)
346
+ else:
347
+ logger.info(
348
+ f"Adopting healthy OpenCode server pid={pid} from previous run"
349
+ )
350
+ else:
351
+ # Server is healthy but we can't find its PID - clear stale file
352
+ logger.info(
353
+ f"Adopting healthy OpenCode server (clearing stale PID file, pid={pid} not found)"
354
+ )
355
+ self._clear_pid_file()
356
+ return
357
+
358
+ cmd = self._get_pid_command(pid)
359
+ if cmd and self._is_opencode_serve_cmd(cmd, self.port) and self._pid_exists(pid):
360
+ await self._terminate_pid(pid, reason="orphaned and unhealthy")
361
+ self._clear_pid_file()
362
+
363
+ async def ensure_running(self) -> str:
364
+ async with self._lock:
365
+ await self._cleanup_orphaned_managed_server()
366
+
367
+ if await self._is_healthy():
368
+ # If the server is already running (e.g., started by a previous run),
369
+ # record its PID so shutdown can clean it up.
370
+ if not self._read_pid_file():
371
+ pids = self._find_opencode_serve_pids(self.port)
372
+ if pids:
373
+ pid = pids[0]
374
+ cmd = self._get_pid_command(pid)
375
+ if cmd and self._is_opencode_serve_cmd(cmd, self.port):
376
+ self._write_pid_file(pid)
377
+
378
+ self._base_url = f"http://{self.host}:{self.port}"
379
+ return self.base_url
380
+
381
+ if not self._is_port_available():
382
+ for pid in self._find_opencode_serve_pids(self.port):
383
+ await self._terminate_pid(pid, reason="port occupied but unhealthy")
384
+ await asyncio.sleep(0.5)
385
+
386
+ if not self._is_port_available():
387
+ raise RuntimeError(
388
+ f"OpenCode port {self.port} is already in use but the server is not responding. "
389
+ "Stop the process using this port or set OPENCODE_PORT to a free port."
390
+ )
391
+
392
+ await self._start_server()
393
+ return self.base_url
394
+
395
+ async def _is_healthy(self) -> bool:
396
+ try:
397
+ session = await self._get_http_session()
398
+ async with session.get(
399
+ f"{self.base_url}/global/health", timeout=aiohttp.ClientTimeout(total=5)
400
+ ) as resp:
401
+ if resp.status == 200:
402
+ data = await resp.json()
403
+ return data.get("healthy", False)
404
+ except Exception as e:
405
+ logger.debug(f"Health check failed: {e}")
406
+ return False
407
+
408
+ async def _start_server(self) -> None:
409
+ if self._process and self._process.returncode is None:
410
+ try:
411
+ self._process.terminate()
412
+ await asyncio.wait_for(self._process.wait(), timeout=5)
413
+ except Exception:
414
+ self._process.kill()
415
+
416
+ # Ensure any stale pid file is cleared before starting.
417
+ self._clear_pid_file()
418
+
419
+ cmd = [
420
+ self.binary,
421
+ "serve",
422
+ f"--hostname={self.host}",
423
+ f"--port={self.port}",
424
+ ]
425
+
426
+ logger.info(f"Starting OpenCode server: {' '.join(cmd)}")
427
+
428
+ env = os.environ.copy()
429
+ env["OPENCODE_ENABLE_EXA"] = "1"
430
+
431
+ try:
432
+ self._process = await asyncio.create_subprocess_exec(
433
+ *cmd,
434
+ stdout=asyncio.subprocess.DEVNULL,
435
+ stderr=asyncio.subprocess.DEVNULL,
436
+ env=env,
437
+ )
438
+ if self._process and self._process.pid:
439
+ self._write_pid_file(self._process.pid)
440
+ except FileNotFoundError:
441
+ raise RuntimeError(
442
+ f"OpenCode CLI not found at '{self.binary}'. "
443
+ "Please install OpenCode or set OPENCODE_CLI_PATH."
444
+ )
445
+
446
+ start_time = time.monotonic()
447
+ while time.monotonic() - start_time < SERVER_START_TIMEOUT:
448
+ if await self._is_healthy():
449
+ self._base_url = f"http://{self.host}:{self.port}"
450
+ logger.info(f"OpenCode server started at {self._base_url}")
451
+ return
452
+ await asyncio.sleep(0.5)
453
+
454
+ exit_code = self._process.returncode
455
+ self._clear_pid_file()
456
+ self._process = None
457
+ raise RuntimeError(
458
+ f"OpenCode server failed to start within {SERVER_START_TIMEOUT}s. "
459
+ f"Process exit code: {exit_code}"
460
+ )
461
+
462
+ async def stop(self) -> None:
463
+ async with self._lock:
464
+ if self._http_session:
465
+ await self._http_session.close()
466
+ self._http_session = None
467
+ self._http_session_loop = None
468
+
469
+ # Don't terminate OpenCode server on vibe-remote shutdown.
470
+ # Let it continue running so the next vibe-remote instance can adopt it.
471
+ # This prevents interrupting tasks that are still in progress.
472
+ logger.info("OpenCode server left running for next vibe-remote instance to adopt")
473
+
474
+ # Keep pid_file so next instance knows about the running server.
475
+ self._process = None
476
+
477
+ def stop_sync(self) -> None:
478
+ if self._http_session and self._http_session_loop:
479
+ try:
480
+ future = asyncio.run_coroutine_threadsafe(
481
+ self._http_session.close(), self._http_session_loop
482
+ )
483
+ future.result(timeout=5)
484
+ except Exception as e:
485
+ logger.debug(f"Failed to close OpenCode HTTP session: {e}")
486
+ finally:
487
+ self._http_session = None
488
+ self._http_session_loop = None
489
+
490
+ # Don't terminate OpenCode server on vibe-remote shutdown.
491
+ # Let it continue running so the next vibe-remote instance can adopt it.
492
+ # This prevents interrupting tasks that are still in progress.
493
+ logger.info("OpenCode server left running for next vibe-remote instance to adopt")
494
+
495
+ # Keep pid_file so next instance knows about the running server.
496
+ # Don't clear _process reference - just let it be garbage collected.
497
+ self._process = None
498
+
499
+ @classmethod
500
+ def stop_instance_sync(cls) -> None:
501
+ if cls._instance:
502
+ cls._instance.stop_sync()
503
+ return
504
+
505
+ # Don't terminate OpenCode server on vibe-remote shutdown.
506
+ # Let it continue running so the next vibe-remote instance can adopt it.
507
+ logger.info("OpenCode server left running for next vibe-remote instance to adopt")
508
+
509
+ async def create_session(
510
+ self, directory: str, title: Optional[str] = None
511
+ ) -> Dict[str, Any]:
512
+ session = await self._get_http_session()
513
+ body: Dict[str, Any] = {}
514
+ if title:
515
+ body["title"] = title
516
+
517
+ async with session.post(
518
+ f"{self.base_url}/session",
519
+ json=body,
520
+ headers={"x-opencode-directory": directory},
521
+ ) as resp:
522
+ if resp.status != 200:
523
+ text = await resp.text()
524
+ raise RuntimeError(f"Failed to create session: {resp.status} {text}")
525
+ return await resp.json()
526
+
527
+ async def send_message(
528
+ self,
529
+ session_id: str,
530
+ directory: str,
531
+ text: str,
532
+ agent: Optional[str] = None,
533
+ model: Optional[Dict[str, str]] = None,
534
+ reasoning_effort: Optional[str] = None,
535
+ ) -> Dict[str, Any]:
536
+ session = await self._get_http_session()
537
+
538
+ body: Dict[str, Any] = {
539
+ "parts": [{"type": "text", "text": text}],
540
+ }
541
+ if agent:
542
+ body["agent"] = agent
543
+ if model:
544
+ body["model"] = model
545
+ if reasoning_effort:
546
+ body["reasoningEffort"] = reasoning_effort
547
+
548
+ async with session.post(
549
+ f"{self.base_url}/session/{session_id}/message",
550
+ json=body,
551
+ headers={"x-opencode-directory": directory},
552
+ ) as resp:
553
+ if resp.status != 200:
554
+ error_text = await resp.text()
555
+ raise RuntimeError(
556
+ f"Failed to send message: {resp.status} {error_text}"
557
+ )
558
+ return await resp.json()
559
+
560
+ async def prompt_async(
561
+ self,
562
+ session_id: str,
563
+ directory: str,
564
+ text: str,
565
+ agent: Optional[str] = None,
566
+ model: Optional[Dict[str, str]] = None,
567
+ reasoning_effort: Optional[str] = None,
568
+ ) -> None:
569
+ """Start a prompt asynchronously without holding the HTTP request open."""
570
+ session = await self._get_http_session()
571
+
572
+ body: Dict[str, Any] = {
573
+ "parts": [{"type": "text", "text": text}],
574
+ }
575
+ if agent:
576
+ body["agent"] = agent
577
+ if model:
578
+ body["model"] = model
579
+ if reasoning_effort:
580
+ body["reasoningEffort"] = reasoning_effort
581
+
582
+ async with session.post(
583
+ f"{self.base_url}/session/{session_id}/prompt_async",
584
+ json=body,
585
+ headers={"x-opencode-directory": directory},
586
+ ) as resp:
587
+ # OpenCode returns 204 when accepted.
588
+ if resp.status not in (200, 204):
589
+ error_text = await resp.text()
590
+ raise RuntimeError(
591
+ f"Failed to start async prompt: {resp.status} {error_text}"
592
+ )
593
+
594
+ async def list_messages(
595
+ self, session_id: str, directory: str
596
+ ) -> List[Dict[str, Any]]:
597
+ session = await self._get_http_session()
598
+ async with session.get(
599
+ f"{self.base_url}/session/{session_id}/message",
600
+ headers={"x-opencode-directory": directory},
601
+ ) as resp:
602
+ if resp.status != 200:
603
+ error_text = await resp.text()
604
+ raise RuntimeError(
605
+ f"Failed to list messages: {resp.status} {error_text}"
606
+ )
607
+ return await resp.json()
608
+
609
+ async def get_message(
610
+ self, session_id: str, message_id: str, directory: str
611
+ ) -> Dict[str, Any]:
612
+ session = await self._get_http_session()
613
+ async with session.get(
614
+ f"{self.base_url}/session/{session_id}/message/{message_id}",
615
+ headers={"x-opencode-directory": directory},
616
+ ) as resp:
617
+ if resp.status != 200:
618
+ error_text = await resp.text()
619
+ raise RuntimeError(
620
+ f"Failed to get message: {resp.status} {error_text}"
621
+ )
622
+ return await resp.json()
623
+
624
+ async def list_questions(
625
+ self, directory: Optional[str] = None
626
+ ) -> List[Dict[str, Any]]:
627
+ session = await self._get_http_session()
628
+ params = {"directory": directory} if directory else None
629
+ async with session.get(
630
+ f"{self.base_url}/question",
631
+ params=params,
632
+ ) as resp:
633
+ if resp.status != 200:
634
+ error_text = await resp.text()
635
+ raise RuntimeError(
636
+ f"Failed to list questions: {resp.status} {error_text}"
637
+ )
638
+ data = await resp.json()
639
+ return data if isinstance(data, list) else []
640
+
641
+ async def reply_question(
642
+ self, question_id: str, directory: str, answers: List[List[str]]
643
+ ) -> bool:
644
+ session = await self._get_http_session()
645
+ async with session.post(
646
+ f"{self.base_url}/question/{question_id}/reply",
647
+ params={"directory": directory},
648
+ json={"answers": answers},
649
+ ) as resp:
650
+ if resp.status != 200:
651
+ error_text = await resp.text()
652
+ raise RuntimeError(
653
+ f"Failed to reply question: {resp.status} {error_text}"
654
+ )
655
+ data = await resp.json()
656
+ return bool(data)
657
+
658
+ async def abort_session(self, session_id: str, directory: str) -> bool:
659
+
660
+ session = await self._get_http_session()
661
+
662
+ try:
663
+ async with session.post(
664
+ f"{self.base_url}/session/{session_id}/abort",
665
+ headers={"x-opencode-directory": directory},
666
+ ) as resp:
667
+ return resp.status == 200
668
+ except Exception as e:
669
+ logger.warning(f"Failed to abort session {session_id}: {e}")
670
+ return False
671
+
672
+ async def get_session(
673
+ self, session_id: str, directory: str
674
+ ) -> Optional[Dict[str, Any]]:
675
+ session = await self._get_http_session()
676
+ try:
677
+ async with session.get(
678
+ f"{self.base_url}/session/{session_id}",
679
+ headers={"x-opencode-directory": directory},
680
+ ) as resp:
681
+ if resp.status == 200:
682
+ return await resp.json()
683
+ return None
684
+ except Exception as e:
685
+ logger.debug(f"Failed to get session {session_id}: {e}")
686
+ return None
687
+
688
+ async def get_available_agents(self, directory: str) -> List[Dict[str, Any]]:
689
+ """Fetch available agents from OpenCode server.
690
+
691
+ Returns:
692
+ List of agent dicts with 'name', 'mode', 'native', etc.
693
+ """
694
+ session = await self._get_http_session()
695
+ try:
696
+ async with session.get(
697
+ f"{self.base_url}/agent",
698
+ headers={"x-opencode-directory": directory},
699
+ ) as resp:
700
+ if resp.status == 200:
701
+ agents = await resp.json()
702
+ # Filter to primary agents (build, plan), exclude hidden/subagent
703
+ return [
704
+ a for a in agents
705
+ if a.get("mode") == "primary" and not a.get("hidden", False)
706
+ ]
707
+ return []
708
+ except Exception as e:
709
+ logger.warning(f"Failed to get available agents: {e}")
710
+ return []
711
+
712
+ async def get_available_models(self, directory: str) -> Dict[str, Any]:
713
+ """Fetch available models from OpenCode server.
714
+
715
+ Returns:
716
+ Dict with 'providers' list and 'default' dict mapping provider to default model.
717
+ """
718
+ session = await self._get_http_session()
719
+ try:
720
+ async with session.get(
721
+ f"{self.base_url}/config/providers",
722
+ headers={"x-opencode-directory": directory},
723
+ ) as resp:
724
+ if resp.status == 200:
725
+ return await resp.json()
726
+ return {"providers": [], "default": {}}
727
+ except Exception as e:
728
+ logger.warning(f"Failed to get available models: {e}")
729
+ return {"providers": [], "default": {}}
730
+
731
+ async def get_default_config(self, directory: str) -> Dict[str, Any]:
732
+ """Fetch current default config from OpenCode server.
733
+
734
+ Returns:
735
+ Config dict including 'model' (current default), 'agent' configs, etc.
736
+ """
737
+ session = await self._get_http_session()
738
+ try:
739
+ async with session.get(
740
+ f"{self.base_url}/config",
741
+ headers={"x-opencode-directory": directory},
742
+ ) as resp:
743
+ if resp.status == 200:
744
+ return await resp.json()
745
+ return {}
746
+ except Exception as e:
747
+ logger.warning(f"Failed to get default config: {e}")
748
+ return {}
749
+
750
+ def _load_opencode_user_config(self) -> Optional[Dict[str, Any]]:
751
+ """Load and cache opencode.json config file.
752
+
753
+ Returns:
754
+ Parsed config dict, or None if file doesn't exist or is invalid.
755
+ """
756
+ import json
757
+ from pathlib import Path
758
+
759
+ config_path = Path.home() / ".config" / "opencode" / "opencode.json"
760
+ if not config_path.exists():
761
+ return None
762
+
763
+ try:
764
+ with open(config_path, "r") as f:
765
+ config = json.load(f)
766
+ if not isinstance(config, dict):
767
+ logger.warning("opencode.json root is not a dict")
768
+ return None
769
+ return config
770
+ except Exception as e:
771
+ logger.warning(f"Failed to load opencode.json: {e}")
772
+ return None
773
+
774
+ def _get_agent_config(
775
+ self, config: Dict[str, Any], agent_name: Optional[str]
776
+ ) -> Dict[str, Any]:
777
+ """Get agent-specific config from opencode.json with type safety.
778
+
779
+ Args:
780
+ config: Parsed opencode.json config
781
+ agent_name: Name of the agent, or None
782
+
783
+ Returns:
784
+ Agent config dict, or empty dict if not found/invalid.
785
+ """
786
+ if not agent_name:
787
+ return {}
788
+ agents = config.get("agent", {})
789
+ if not isinstance(agents, dict):
790
+ return {}
791
+ agent_config = agents.get(agent_name, {})
792
+ if not isinstance(agent_config, dict):
793
+ return {}
794
+ return agent_config
795
+
796
+ def get_agent_model_from_config(self, agent_name: Optional[str]) -> Optional[str]:
797
+ """Read agent's default model from user's opencode.json config file.
798
+
799
+ This is a workaround for OpenCode server not using agent-specific models
800
+ when only the agent parameter is passed to the message API.
801
+
802
+ Args:
803
+ agent_name: Name of the agent (e.g., "build", "plan"), or None for global default
804
+
805
+ Returns:
806
+ Model string in "provider/model" format, or None if not configured.
807
+ """
808
+ config = self._load_opencode_user_config()
809
+ if not config:
810
+ return None
811
+
812
+ # Try agent-specific model first
813
+ agent_config = self._get_agent_config(config, agent_name)
814
+ model = agent_config.get("model")
815
+ if isinstance(model, str) and model:
816
+ logger.debug(f"Found model '{model}' for agent '{agent_name}' in opencode.json")
817
+ return model
818
+
819
+ # Fall back to global default model
820
+ model = config.get("model")
821
+ if isinstance(model, str) and model:
822
+ logger.debug(f"Using global default model '{model}' from opencode.json")
823
+ return model
824
+ return None
825
+
826
+ def get_agent_reasoning_effort_from_config(
827
+ self, agent_name: Optional[str]
828
+ ) -> Optional[str]:
829
+ """Read agent's reasoningEffort from user's opencode.json config file.
830
+
831
+ Args:
832
+ agent_name: Name of the agent (e.g., "build", "plan"), or None for global default
833
+
834
+ Returns:
835
+ reasoningEffort string (e.g., "low", "medium", "high", "xhigh"), or None if not configured.
836
+ """
837
+ config = self._load_opencode_user_config()
838
+ if not config:
839
+ return None
840
+
841
+ # Valid reasoning effort values
842
+ valid_efforts = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
843
+
844
+ # Try agent-specific reasoningEffort first
845
+ agent_config = self._get_agent_config(config, agent_name)
846
+ reasoning_effort = agent_config.get("reasoningEffort")
847
+ if isinstance(reasoning_effort, str) and reasoning_effort:
848
+ if reasoning_effort in valid_efforts:
849
+ logger.debug(
850
+ f"Found reasoningEffort '{reasoning_effort}' for agent '{agent_name}' in opencode.json"
851
+ )
852
+ return reasoning_effort
853
+ else:
854
+ logger.debug(f"Ignoring unknown reasoningEffort '{reasoning_effort}' for agent '{agent_name}'")
855
+
856
+ # Fall back to global default reasoningEffort
857
+ reasoning_effort = config.get("reasoningEffort")
858
+ if isinstance(reasoning_effort, str) and reasoning_effort:
859
+ if reasoning_effort in valid_efforts:
860
+ logger.debug(
861
+ f"Using global default reasoningEffort '{reasoning_effort}' from opencode.json"
862
+ )
863
+ return reasoning_effort
864
+ else:
865
+ logger.debug(f"Ignoring unknown global reasoningEffort '{reasoning_effort}'")
866
+ return None
867
+
868
+ def get_default_agent_from_config(self) -> Optional[str]:
869
+ """Read the default agent from user's opencode.json config file.
870
+
871
+ OpenCode server doesn't automatically use its configured default agent
872
+ when called via API, so we need to read and pass it explicitly.
873
+
874
+ Returns:
875
+ Default agent name (e.g., "build", "plan"), or "build" as fallback.
876
+ """
877
+ # OpenCode doesn't have an explicit "default agent" config field.
878
+ # Users can override via channel settings.
879
+ # Default to "build" agent which uses the agent's configured model,
880
+ # avoiding fallback to global model which may use restricted credentials.
881
+ return "build"
882
+
883
+
884
+ class OpenCodeAgent(BaseAgent):
885
+ """OpenCode Server API integration via HTTP."""
886
+
887
+ name = "opencode"
888
+
889
+ def __init__(self, controller, opencode_config):
890
+ super().__init__(controller)
891
+ self.opencode_config = opencode_config
892
+ self._server_manager: Optional[OpenCodeServerManager] = None
893
+ self._active_requests: Dict[str, asyncio.Task] = {}
894
+ self._request_sessions: Dict[str, Tuple[str, str, str]] = {}
895
+ self._session_locks: Dict[str, asyncio.Lock] = {}
896
+ self._initialized_sessions: set[str] = set()
897
+ self._pending_questions: Dict[str, Dict[str, Any]] = {}
898
+
899
+ async def _get_server(self) -> OpenCodeServerManager:
900
+ if self._server_manager is None:
901
+ self._server_manager = await OpenCodeServerManager.get_instance(
902
+ binary=self.opencode_config.binary,
903
+ port=self.opencode_config.port,
904
+ request_timeout_seconds=self.opencode_config.request_timeout_seconds,
905
+ )
906
+ return self._server_manager
907
+
908
+ def _get_session_lock(self, base_session_id: str) -> asyncio.Lock:
909
+ if base_session_id not in self._session_locks:
910
+ self._session_locks[base_session_id] = asyncio.Lock()
911
+ return self._session_locks[base_session_id]
912
+
913
+ async def _wait_for_session_idle(
914
+ self,
915
+ server: OpenCodeServerManager,
916
+ session_id: str,
917
+ directory: str,
918
+ timeout_seconds: float = 15.0,
919
+ ) -> None:
920
+ deadline = time.monotonic() + timeout_seconds
921
+ while time.monotonic() < deadline:
922
+ try:
923
+ messages = await server.list_messages(session_id, directory)
924
+ except Exception as err:
925
+ logger.debug(f"Failed to poll OpenCode session {session_id} for idle: {err}")
926
+ await asyncio.sleep(1.0)
927
+ continue
928
+
929
+ in_progress = False
930
+ for message in messages:
931
+ info = message.get("info", {})
932
+ if info.get("role") != "assistant":
933
+ continue
934
+ time_info = info.get("time") or {}
935
+ if not time_info.get("completed"):
936
+ in_progress = True
937
+ break
938
+
939
+ if not in_progress:
940
+ return
941
+
942
+ await asyncio.sleep(1.0)
943
+
944
+ logger.warning(
945
+ "OpenCode session %s did not reach idle state within %.1fs",
946
+ session_id,
947
+ timeout_seconds,
948
+ )
949
+
950
+ async def handle_message(self, request: AgentRequest) -> None:
951
+ lock = self._get_session_lock(request.base_session_id)
952
+ open_modal_task: Optional[asyncio.Task] = None
953
+ task: Optional[asyncio.Task] = None
954
+ async with lock:
955
+ pending = self._pending_questions.get(request.base_session_id)
956
+ is_modal_open = pending and request.message == "opencode_question:open_modal"
957
+ is_question_action = pending and request.message.startswith("opencode_question:")
958
+
959
+ existing_task = self._active_requests.get(request.base_session_id)
960
+ if existing_task and not existing_task.done():
961
+ if is_modal_open:
962
+ logger.info(
963
+ "OpenCode session %s running; opening modal without cancel",
964
+ request.base_session_id,
965
+ )
966
+ elif is_question_action:
967
+ logger.info(
968
+ "OpenCode session %s running; cancelling poll task for question reply",
969
+ request.base_session_id,
970
+ )
971
+ existing_task.cancel()
972
+ try:
973
+ await existing_task
974
+ except asyncio.CancelledError:
975
+ pass
976
+ else:
977
+ logger.info(
978
+ "OpenCode session %s already running; cancelling before new request",
979
+ request.base_session_id,
980
+ )
981
+ req_info = self._request_sessions.get(request.base_session_id)
982
+ if req_info:
983
+ server = await self._get_server()
984
+ await server.abort_session(req_info[0], req_info[1])
985
+ await self._wait_for_session_idle(
986
+ server, req_info[0], req_info[1]
987
+ )
988
+ existing_task.cancel()
989
+ try:
990
+ await existing_task
991
+ except asyncio.CancelledError:
992
+ pass
993
+ logger.info(
994
+ "OpenCode session %s cancelled; continuing with new request",
995
+ request.base_session_id,
996
+ )
997
+
998
+ if is_modal_open:
999
+ if hasattr(self.im_client, "open_opencode_question_modal"):
1000
+ open_modal_task = asyncio.create_task(
1001
+ self._open_question_modal(request, pending or {})
1002
+ )
1003
+ else:
1004
+ task = asyncio.create_task(self._process_message(request))
1005
+ self._active_requests[request.base_session_id] = task
1006
+ elif pending:
1007
+ pending_payload = self._pending_questions.pop(request.base_session_id, None)
1008
+ task = asyncio.create_task(
1009
+ self._process_question_answer(request, pending_payload or {})
1010
+ )
1011
+ self._active_requests[request.base_session_id] = task
1012
+ else:
1013
+ task = asyncio.create_task(self._process_message(request))
1014
+ self._active_requests[request.base_session_id] = task
1015
+
1016
+ if open_modal_task:
1017
+ await open_modal_task
1018
+ return
1019
+
1020
+ if not task:
1021
+ return
1022
+
1023
+ try:
1024
+ await task
1025
+ except asyncio.CancelledError:
1026
+ # Task was cancelled (e.g. by /stop), exit gracefully without bubbling
1027
+ logger.debug(f"OpenCode task cancelled for {request.base_session_id}")
1028
+ finally:
1029
+ if self._active_requests.get(request.base_session_id) is task:
1030
+ self._active_requests.pop(request.base_session_id, None)
1031
+ self._request_sessions.pop(request.base_session_id, None)
1032
+
1033
+ def _build_question_selection_note(
1034
+ self, answers_payload: List[List[str]]
1035
+ ) -> str:
1036
+ if not answers_payload:
1037
+ return ""
1038
+
1039
+ if len(answers_payload) == 1:
1040
+ joined = ", ".join([value for value in answers_payload[0] if value])
1041
+ return f"已选择:{joined}" if joined else ""
1042
+
1043
+ lines = []
1044
+ for idx, answers in enumerate(answers_payload, start=1):
1045
+ joined = ", ".join([value for value in answers if value])
1046
+ if joined:
1047
+ lines.append(f"Q{idx}: {joined}")
1048
+ if not lines:
1049
+ return ""
1050
+ return "已选择:\n" + "\n".join(lines)
1051
+
1052
+ async def _open_question_modal(
1053
+ self, request: AgentRequest, pending: Dict[str, Any]
1054
+ ) -> None:
1055
+ trigger_id = None
1056
+ if request.context.platform_specific:
1057
+ trigger_id = request.context.platform_specific.get("trigger_id")
1058
+ if not trigger_id:
1059
+ await self.im_client.send_message(
1060
+ request.context,
1061
+ "Slack did not provide a trigger_id for the modal. Please reply with a custom message.",
1062
+ )
1063
+ return
1064
+
1065
+ if not hasattr(self.im_client, "open_opencode_question_modal"):
1066
+ await self.im_client.send_message(
1067
+ request.context,
1068
+ "Modal UI is not available. Please reply with a custom message.",
1069
+ )
1070
+ return
1071
+
1072
+ try:
1073
+ await self.im_client.open_opencode_question_modal(
1074
+ trigger_id=trigger_id,
1075
+ context=request.context,
1076
+ pending=pending,
1077
+ )
1078
+ except Exception as err:
1079
+ logger.error(f"Failed to open OpenCode question modal: {err}", exc_info=True)
1080
+ await self.im_client.send_message(
1081
+ request.context,
1082
+ f"Failed to open modal: {err}. Please reply with a custom message.",
1083
+ )
1084
+
1085
+ async def _process_question_answer(
1086
+ self, request: AgentRequest, pending: Dict[str, Any]
1087
+ ) -> None:
1088
+ # pending contains: session_id, directory, question_id, questions
1089
+ session_id = pending.get("session_id")
1090
+ directory = pending.get("directory")
1091
+ question_id = pending.get("question_id")
1092
+ option_labels = pending.get("option_labels")
1093
+ option_labels = option_labels if isinstance(option_labels, list) else []
1094
+ question_count = pending.get("question_count")
1095
+ pending_thread_id = pending.get("thread_id")
1096
+ question_message_id = pending.get("prompt_message_id")
1097
+ if pending_thread_id and not request.context.thread_id:
1098
+ request.context.thread_id = pending_thread_id
1099
+ try:
1100
+ question_count_int = int(question_count) if question_count is not None else 1
1101
+ except Exception:
1102
+ question_count_int = 1
1103
+ question_count_int = max(1, question_count_int)
1104
+
1105
+ if not session_id or not directory:
1106
+ await self.controller.emit_agent_message(
1107
+ request.context,
1108
+ "notify",
1109
+ "OpenCode question context is missing; please reply with a custom message.",
1110
+ )
1111
+ return
1112
+
1113
+ server = await self._get_server()
1114
+
1115
+ answer_text = None
1116
+ if request.message.startswith("opencode_question:choose:"):
1117
+ try:
1118
+ choice_idx = int(request.message.rsplit(":", 1)[-1]) - 1
1119
+ if 0 <= choice_idx < len(option_labels):
1120
+ answer_text = str(option_labels[choice_idx]).strip()
1121
+ except Exception:
1122
+ pass
1123
+
1124
+ is_modal_payload = False
1125
+ answers_payload: Optional[List[List[str]]] = None
1126
+ if request.message.startswith("opencode_question:modal:"):
1127
+ is_modal_payload = True
1128
+ try:
1129
+ payload = json.loads(request.message.split(":", 2)[-1])
1130
+ answers = payload.get("answers") if isinstance(payload, dict) else None
1131
+ if isinstance(answers, list) and answers:
1132
+ normalized: List[List[str]] = []
1133
+ for answer in answers:
1134
+ if isinstance(answer, list):
1135
+ normalized.append([str(x) for x in answer if x])
1136
+ elif answer:
1137
+ normalized.append([str(answer)])
1138
+ else:
1139
+ normalized.append([])
1140
+ answers_payload = normalized
1141
+ if normalized:
1142
+ answer_text = " ".join(normalized[0])
1143
+ except Exception:
1144
+ logger.debug("Failed to parse modal answers payload")
1145
+
1146
+ if answer_text is None and request.message.startswith("opencode_question:"):
1147
+ raw_payload = request.message.split(":", 2)[-1]
1148
+ answer_text = raw_payload.strip() if raw_payload else ""
1149
+ # Otherwise user replied with free text.
1150
+ if not answer_text:
1151
+ answer_text = (request.message or "").strip()
1152
+
1153
+ if not answer_text:
1154
+ await self.controller.emit_agent_message(
1155
+ request.context,
1156
+ "notify",
1157
+ "Please reply with an answer.",
1158
+ )
1159
+ return
1160
+
1161
+ if pending:
1162
+ self._pending_questions.pop(request.base_session_id, None)
1163
+
1164
+ if not question_id:
1165
+ # Fallback resolution if the /question listing wasn't available when we first saw the toolcall.
1166
+ call_id = pending.get("call_id")
1167
+ message_id = pending.get("message_id")
1168
+ try:
1169
+ questions = await server.list_questions(directory)
1170
+ if not questions:
1171
+ questions = await server.list_questions()
1172
+ for item in questions:
1173
+ tool = item.get("tool") or {}
1174
+ item_session_id = (
1175
+ item.get("sessionID")
1176
+ or item.get("sessionId")
1177
+ or item.get("session_id")
1178
+ )
1179
+ if item_session_id != session_id:
1180
+ continue
1181
+ if call_id and tool.get("callID") != call_id:
1182
+ continue
1183
+ if message_id and tool.get("messageID") != message_id:
1184
+ continue
1185
+ question_id = item.get("id")
1186
+ questions_obj = item.get("questions")
1187
+ if isinstance(questions_obj, list):
1188
+ question_count_int = max(1, len(questions_obj))
1189
+ break
1190
+ except Exception as err:
1191
+ logger.warning(f"Failed to resolve OpenCode question id: {err}")
1192
+
1193
+ if not question_id:
1194
+ self._pending_questions[request.base_session_id] = pending
1195
+ await self.controller.emit_agent_message(
1196
+ request.context,
1197
+ "notify",
1198
+ "OpenCode is waiting for input, but the question id could not be resolved. Please retry.",
1199
+ )
1200
+ return
1201
+
1202
+ if is_modal_payload and answers_payload is not None:
1203
+ padded = answers_payload[:question_count_int]
1204
+ if len(padded) < question_count_int:
1205
+ padded.extend([[] for _ in range(question_count_int - len(padded))])
1206
+ answers_payload = padded
1207
+ else:
1208
+ answers_payload = [[answer_text] for _ in range(question_count_int)]
1209
+
1210
+ if question_message_id:
1211
+ note = self._build_question_selection_note(answers_payload)
1212
+ fallback_text = pending.get("prompt_text") if isinstance(pending, dict) else None
1213
+ if note:
1214
+ try:
1215
+ updated_text = f"{fallback_text}\n\n{note}" if fallback_text else note
1216
+ await self.im_client.remove_inline_keyboard(
1217
+ request.context,
1218
+ question_message_id,
1219
+ text=updated_text,
1220
+ parse_mode="markdown",
1221
+ )
1222
+ except Exception as err:
1223
+ logger.debug(f"Failed to update question message: {err}")
1224
+ else:
1225
+ try:
1226
+ await self.im_client.remove_inline_keyboard(
1227
+ request.context,
1228
+ question_message_id,
1229
+ text=fallback_text,
1230
+ parse_mode="markdown",
1231
+ )
1232
+ except Exception as err:
1233
+ logger.debug(f"Failed to remove question buttons: {err}")
1234
+
1235
+ try:
1236
+ ok = await server.reply_question(question_id, directory, answers_payload)
1237
+ except Exception as err:
1238
+ logger.warning(f"Failed to reply OpenCode question: {err}")
1239
+ self._pending_questions[request.base_session_id] = pending
1240
+ await self.controller.emit_agent_message(
1241
+ request.context,
1242
+ "notify",
1243
+ f"Failed to submit answer to OpenCode: {err}",
1244
+ )
1245
+ return
1246
+
1247
+ if not ok:
1248
+ self._pending_questions[request.base_session_id] = pending
1249
+ await self.controller.emit_agent_message(
1250
+ request.context,
1251
+ "notify",
1252
+ "OpenCode did not accept the answer. Please retry.",
1253
+ )
1254
+ return
1255
+
1256
+ # After replying, continue polling for final assistant output.
1257
+ baseline_message_ids: set[str] = set()
1258
+ try:
1259
+ baseline_messages = await server.list_messages(session_id, directory)
1260
+ for m in baseline_messages:
1261
+ mid = m.get("info", {}).get("id")
1262
+ if mid:
1263
+ baseline_message_ids.add(mid)
1264
+ except Exception:
1265
+ pass
1266
+
1267
+ poll_interval_seconds = 2.0
1268
+ while True:
1269
+ messages = await server.list_messages(session_id, directory)
1270
+ if messages:
1271
+ last = messages[-1]
1272
+ last_info = last.get("info", {})
1273
+ if (
1274
+ last_info.get("role") == "assistant"
1275
+ and last_info.get("time", {}).get("completed")
1276
+ and last_info.get("finish") != "tool-calls"
1277
+ and last_info.get("id") not in baseline_message_ids
1278
+ ):
1279
+ text = self._extract_response_text(last)
1280
+ if text:
1281
+ await self.emit_result_message(
1282
+ request.context,
1283
+ text,
1284
+ subtype="success",
1285
+ started_at=request.started_at,
1286
+ parse_mode="markdown",
1287
+ )
1288
+ else:
1289
+ await self.emit_result_message(
1290
+ request.context,
1291
+ "(No response from OpenCode)",
1292
+ subtype="warning",
1293
+ started_at=request.started_at,
1294
+ )
1295
+ return
1296
+
1297
+ await asyncio.sleep(poll_interval_seconds)
1298
+
1299
+ async def _process_message(self, request: AgentRequest) -> None:
1300
+ try:
1301
+ server = await self._get_server()
1302
+ await server.ensure_running()
1303
+ except Exception as e:
1304
+ logger.error(f"Failed to start OpenCode server: {e}", exc_info=True)
1305
+ await self.controller.emit_agent_message(
1306
+ request.context,
1307
+ "notify",
1308
+ f"Failed to start OpenCode server: {e}",
1309
+ )
1310
+ return
1311
+
1312
+ await self._delete_ack(request)
1313
+
1314
+ if not os.path.exists(request.working_path):
1315
+ os.makedirs(request.working_path, exist_ok=True)
1316
+
1317
+ session_id = self.settings_manager.get_agent_session_id(
1318
+ request.settings_key,
1319
+ request.base_session_id,
1320
+ agent_name=self.name,
1321
+ )
1322
+
1323
+ if not session_id:
1324
+ try:
1325
+ session_data = await server.create_session(
1326
+ directory=request.working_path,
1327
+ title=f"vibe-remote:{request.base_session_id}",
1328
+ )
1329
+ session_id = session_data.get("id")
1330
+ if session_id:
1331
+ self.settings_manager.set_agent_session_mapping(
1332
+ request.settings_key,
1333
+ self.name,
1334
+ request.base_session_id,
1335
+ session_id,
1336
+ )
1337
+ logger.info(
1338
+ f"Created OpenCode session {session_id} for {request.base_session_id}"
1339
+ )
1340
+ except Exception as e:
1341
+ logger.error(f"Failed to create OpenCode session: {e}", exc_info=True)
1342
+ await self.controller.emit_agent_message(
1343
+ request.context,
1344
+ "notify",
1345
+ f"Failed to create OpenCode session: {e}",
1346
+ )
1347
+ return
1348
+ else:
1349
+ existing = await server.get_session(session_id, request.working_path)
1350
+ if not existing:
1351
+ try:
1352
+ session_data = await server.create_session(
1353
+ directory=request.working_path,
1354
+ title=f"vibe-remote:{request.base_session_id}",
1355
+ )
1356
+ session_id = session_data.get("id")
1357
+ if session_id:
1358
+ self.settings_manager.set_agent_session_mapping(
1359
+ request.settings_key,
1360
+ self.name,
1361
+ request.base_session_id,
1362
+ session_id,
1363
+ )
1364
+ logger.info(
1365
+ f"Recreated OpenCode session {session_id} for {request.base_session_id}"
1366
+ )
1367
+ except Exception as e:
1368
+ logger.error(f"Failed to recreate session: {e}", exc_info=True)
1369
+ await self.controller.emit_agent_message(
1370
+ request.context,
1371
+ "notify",
1372
+ f"Failed to create OpenCode session: {e}",
1373
+ )
1374
+ return
1375
+
1376
+ if not session_id:
1377
+ await self.controller.emit_agent_message(
1378
+ request.context,
1379
+ "notify",
1380
+ "Failed to obtain OpenCode session ID",
1381
+ )
1382
+ return
1383
+
1384
+ self._request_sessions[request.base_session_id] = (
1385
+ session_id,
1386
+ request.working_path,
1387
+ request.settings_key,
1388
+ )
1389
+
1390
+ if session_id not in self._initialized_sessions:
1391
+ self._initialized_sessions.add(session_id)
1392
+ system_text = self.im_client.formatter.format_system_message(
1393
+ request.working_path, "init", session_id
1394
+ )
1395
+ await self.controller.emit_agent_message(
1396
+ request.context,
1397
+ "system",
1398
+ system_text,
1399
+ parse_mode="markdown",
1400
+ )
1401
+
1402
+ try:
1403
+ # Get per-channel overrides from settings.json
1404
+ override_agent, override_model, override_reasoning = (
1405
+ self.controller.get_opencode_overrides(request.context)
1406
+ )
1407
+
1408
+ override_agent = request.subagent_name or override_agent
1409
+ if request.subagent_name:
1410
+ override_model = request.subagent_model
1411
+ override_reasoning = request.subagent_reasoning_effort
1412
+
1413
+ if request.subagent_name and not override_model:
1414
+ override_model = server.get_agent_model_from_config(request.subagent_name)
1415
+ if request.subagent_name and not override_reasoning:
1416
+ override_reasoning = server.get_agent_reasoning_effort_from_config(
1417
+ request.subagent_name
1418
+ )
1419
+
1420
+ # Determine agent to use
1421
+ # Priority: 1) channel override or prefix subagent, 2) opencode.json default, 3) None (let OpenCode decide)
1422
+ agent_to_use = override_agent
1423
+ if not agent_to_use:
1424
+ agent_to_use = server.get_default_agent_from_config()
1425
+
1426
+ # Determine model to use
1427
+ # Priority: 1) channel override or prefix subagent, 2) agent's config model, 3) global opencode.json model
1428
+ model_dict = None
1429
+ model_str = override_model
1430
+ if not model_str:
1431
+ # OpenCode server doesn't use agent's configured model when called via API,
1432
+ # so we read it from opencode.json explicitly
1433
+ model_str = server.get_agent_model_from_config(agent_to_use)
1434
+ if model_str:
1435
+ parts = model_str.split("/", 1)
1436
+ if len(parts) == 2:
1437
+ model_dict = {"providerID": parts[0], "modelID": parts[1]}
1438
+
1439
+ # Determine reasoningEffort to use
1440
+ # Priority: 1) channel override or prefix subagent, 2) agent's config, 3) global opencode.json config
1441
+ reasoning_effort = override_reasoning
1442
+ if not reasoning_effort:
1443
+ reasoning_effort = server.get_agent_reasoning_effort_from_config(agent_to_use)
1444
+
1445
+ # Use OpenCode's async prompt API so long-running turns don't hold a single HTTP request.
1446
+ baseline_message_ids: set[str] = set()
1447
+ try:
1448
+ baseline_messages = await server.list_messages(
1449
+ session_id=session_id,
1450
+ directory=request.working_path,
1451
+ )
1452
+ for message in baseline_messages:
1453
+ message_id = message.get("info", {}).get("id")
1454
+ if message_id:
1455
+ baseline_message_ids.add(message_id)
1456
+ except Exception as err:
1457
+ logger.debug(
1458
+ f"Failed to snapshot OpenCode messages before prompt: {err}"
1459
+ )
1460
+
1461
+ await server.prompt_async(
1462
+ session_id=session_id,
1463
+ directory=request.working_path,
1464
+ text=request.message,
1465
+ agent=agent_to_use,
1466
+ model=model_dict,
1467
+ reasoning_effort=reasoning_effort,
1468
+ )
1469
+
1470
+ logger.info(
1471
+ "Starting OpenCode poll loop for %s (thread=%s, cwd=%s)",
1472
+ session_id,
1473
+ request.base_session_id,
1474
+ request.working_path,
1475
+ )
1476
+
1477
+ seen_tool_calls: set[str] = set()
1478
+ emitted_assistant_messages: set[str] = set()
1479
+ poll_interval_seconds = 2.0
1480
+ final_text: Optional[str] = None
1481
+
1482
+ # Error retry tracking
1483
+ error_retry_count = 0
1484
+ error_retry_limit = getattr(self.opencode_config, "error_retry_limit", 1)
1485
+ last_error_message_id: Optional[str] = None
1486
+
1487
+ def _relative_path(path: str) -> str:
1488
+ return self._to_relative_path(path, request.working_path)
1489
+
1490
+ poll_iter = 0
1491
+ while True:
1492
+ poll_iter += 1
1493
+ try:
1494
+ messages = await server.list_messages(
1495
+ session_id=session_id,
1496
+ directory=request.working_path,
1497
+ )
1498
+ if poll_iter % 5 == 0:
1499
+ last_info = messages[-1].get("info", {}) if messages else {}
1500
+ logger.info(
1501
+ "OpenCode poll heartbeat %s iter=%s last=%s role=%s completed=%s finish=%s error=%s",
1502
+ session_id,
1503
+ poll_iter,
1504
+ last_info.get("id"),
1505
+ last_info.get("role"),
1506
+ bool(last_info.get("time", {}).get("completed")),
1507
+ last_info.get("finish"),
1508
+ bool(last_info.get("error")),
1509
+ )
1510
+ except Exception as poll_err:
1511
+ logger.warning(f"Failed to poll OpenCode messages: {poll_err}")
1512
+ await asyncio.sleep(poll_interval_seconds)
1513
+ continue
1514
+
1515
+ for message in messages:
1516
+ info = message.get("info", {})
1517
+ message_id = info.get("id")
1518
+ if not message_id or message_id in baseline_message_ids:
1519
+ continue
1520
+ if info.get("role") != "assistant":
1521
+ continue
1522
+
1523
+ for part in message.get("parts", []) or []:
1524
+ if part.get("type") != "tool":
1525
+ continue
1526
+ call_key = part.get("callID") or part.get("id")
1527
+ if not call_key or call_key in seen_tool_calls:
1528
+ continue
1529
+ tool_name = part.get("tool") or "tool"
1530
+ tool_state = part.get("state") or {}
1531
+ tool_input = tool_state.get("input") or {}
1532
+
1533
+ if tool_name == "question" and tool_state.get("status") != "completed":
1534
+ logger.info(
1535
+ "Detected question toolcall for %s message=%s callID=%s",
1536
+ session_id,
1537
+ message_id,
1538
+ part.get("callID"),
1539
+ )
1540
+
1541
+ # Always render the question text from the tool input so the
1542
+ # user can answer even if /question listing is temporarily empty.
1543
+ qlist = tool_input.get("questions") if isinstance(tool_input, dict) else None
1544
+ qlist = qlist if isinstance(qlist, list) else []
1545
+
1546
+ question_fetch_deadline = time.monotonic() + 60.0
1547
+ question_fetch_delays = [
1548
+ 1.0,
1549
+ 2.0,
1550
+ 3.0,
1551
+ 4.0,
1552
+ 6.0,
1553
+ 8.0,
1554
+ 10.0,
1555
+ 12.0,
1556
+ 14.0,
1557
+ ]
1558
+ max_question_attempts = 10
1559
+
1560
+ def _question_delay(attempt_index: int) -> float:
1561
+ if attempt_index >= len(question_fetch_delays):
1562
+ return 0.0
1563
+ remaining = question_fetch_deadline - time.monotonic()
1564
+ if remaining <= 0:
1565
+ return 0.0
1566
+ return min(question_fetch_delays[attempt_index], remaining)
1567
+
1568
+ question_id = None
1569
+ questions_listing: List[Dict[str, Any]] = []
1570
+ list_attempts = 0
1571
+ last_list_err: Optional[Exception] = None
1572
+ for attempt in range(max_question_attempts):
1573
+ list_attempts = attempt + 1
1574
+ try:
1575
+ questions_listing = await server.list_questions(
1576
+ request.working_path
1577
+ )
1578
+ if not questions_listing:
1579
+ questions_listing = await server.list_questions()
1580
+ last_list_err = None
1581
+ except Exception as err:
1582
+ last_list_err = err
1583
+ questions_listing = []
1584
+ if questions_listing:
1585
+ break
1586
+ if attempt < max_question_attempts - 1:
1587
+ delay = _question_delay(attempt)
1588
+ if delay <= 0:
1589
+ break
1590
+ await asyncio.sleep(delay)
1591
+
1592
+ if last_list_err and not questions_listing:
1593
+ logger.warning(
1594
+ f"Failed to fetch questions listing for prompt fallback: {last_list_err}"
1595
+ )
1596
+
1597
+ logger.info(
1598
+ "Question list fetch for %s: dir=%s attempts=%s items=%s",
1599
+ session_id,
1600
+ request.working_path,
1601
+ list_attempts,
1602
+ len(questions_listing),
1603
+ )
1604
+
1605
+ if questions_listing:
1606
+ try:
1607
+ item_sessions = [
1608
+ (
1609
+ item.get("sessionID")
1610
+ or item.get("sessionId")
1611
+ or item.get("session_id")
1612
+ )
1613
+ for item in questions_listing
1614
+ ]
1615
+ logger.info(
1616
+ "Question list sessions for %s: %s",
1617
+ session_id,
1618
+ item_sessions,
1619
+ )
1620
+ except Exception:
1621
+ pass
1622
+
1623
+ if questions_listing and not qlist:
1624
+ try:
1625
+ first_questions = questions_listing[0].get("questions")
1626
+ if not isinstance(first_questions, list):
1627
+ first_questions = []
1628
+ listing_preview = {
1629
+ "id": questions_listing[0].get("id"),
1630
+ "sessionID": questions_listing[0].get("sessionID"),
1631
+ "tool": questions_listing[0].get("tool"),
1632
+ "questions_len": len(first_questions),
1633
+ }
1634
+ logger.info(
1635
+ "Question list preview for %s: %s",
1636
+ session_id,
1637
+ listing_preview,
1638
+ )
1639
+ except Exception:
1640
+ pass
1641
+
1642
+ matched_item = None
1643
+ if questions_listing:
1644
+ session_items: List[Dict[str, Any]] = []
1645
+ for item in questions_listing:
1646
+ item_session_id = (
1647
+ item.get("sessionID")
1648
+ or item.get("sessionId")
1649
+ or item.get("session_id")
1650
+ )
1651
+ if item_session_id == session_id:
1652
+ session_items.append(item)
1653
+
1654
+ for item in session_items:
1655
+ tool_meta = item.get("tool") or {}
1656
+ if part.get("callID") and tool_meta.get("callID") == part.get("callID"):
1657
+ matched_item = item
1658
+ break
1659
+ if message_id and tool_meta.get("messageID") == message_id:
1660
+ matched_item = item
1661
+ break
1662
+
1663
+ if matched_item is None and session_items:
1664
+ matched_item = session_items[0]
1665
+
1666
+ if matched_item is None and part.get("callID"):
1667
+ for item in questions_listing:
1668
+ tool_meta = item.get("tool") or {}
1669
+ if tool_meta.get("callID") == part.get("callID"):
1670
+ matched_item = item
1671
+ break
1672
+
1673
+ if matched_item is None and message_id:
1674
+ for item in questions_listing:
1675
+ tool_meta = item.get("tool") or {}
1676
+ if tool_meta.get("messageID") == message_id:
1677
+ matched_item = item
1678
+ break
1679
+
1680
+ if matched_item is None and len(questions_listing) == 1:
1681
+ matched_item = questions_listing[0]
1682
+
1683
+ if matched_item:
1684
+ question_id = matched_item.get("id")
1685
+ if not qlist:
1686
+ q_obj = matched_item.get("questions")
1687
+ if isinstance(q_obj, list):
1688
+ qlist = q_obj
1689
+
1690
+ if not qlist and message_id:
1691
+ msg_attempts = 0
1692
+ last_msg_err: Optional[Exception] = None
1693
+ full_message: Optional[Dict[str, Any]] = None
1694
+ for attempt in range(max_question_attempts):
1695
+ msg_attempts = attempt + 1
1696
+ try:
1697
+ full_message = await server.get_message(
1698
+ session_id=session_id,
1699
+ message_id=message_id,
1700
+ directory=request.working_path,
1701
+ )
1702
+ last_msg_err = None
1703
+ except Exception as err:
1704
+ last_msg_err = err
1705
+ full_message = None
1706
+
1707
+ if full_message:
1708
+ for msg_part in full_message.get("parts", []) or []:
1709
+ if msg_part.get("type") != "tool":
1710
+ continue
1711
+ if msg_part.get("tool") != "question":
1712
+ continue
1713
+ msg_call_id = msg_part.get("callID") or msg_part.get("id")
1714
+ if call_key and msg_call_id and msg_call_id != call_key:
1715
+ continue
1716
+ msg_state = msg_part.get("state") or {}
1717
+ msg_input = msg_state.get("input") or {}
1718
+ msg_questions = (
1719
+ msg_input.get("questions")
1720
+ if isinstance(msg_input, dict)
1721
+ else None
1722
+ )
1723
+ if isinstance(msg_questions, list):
1724
+ qlist = msg_questions
1725
+ break
1726
+ if qlist:
1727
+ break
1728
+ if attempt < max_question_attempts - 1:
1729
+ delay = _question_delay(attempt)
1730
+ if delay <= 0:
1731
+ break
1732
+ await asyncio.sleep(delay)
1733
+
1734
+ if last_msg_err and not qlist:
1735
+ logger.warning(
1736
+ f"Failed to fetch full question input from message {message_id}: {last_msg_err}"
1737
+ )
1738
+ if full_message is not None:
1739
+ parts = full_message.get("parts", []) or []
1740
+ tool_parts = [
1741
+ p for p in parts if p.get("type") == "tool"
1742
+ ]
1743
+ logger.info(
1744
+ "Question message fetch for %s: attempts=%s parts=%s tool_parts=%s",
1745
+ session_id,
1746
+ msg_attempts,
1747
+ len(parts),
1748
+ len(tool_parts),
1749
+ )
1750
+
1751
+ option_labels: list[str] = []
1752
+ lines: list[str] = []
1753
+ for q_idx, q in enumerate(qlist or []):
1754
+ if not isinstance(q, dict):
1755
+ continue
1756
+ title = (q.get("header") or f"Question {q_idx + 1}").strip()
1757
+ prompt = (q.get("question") or "").strip()
1758
+ options_raw = q.get("options")
1759
+ options: List[Dict[str, Any]] = (
1760
+ options_raw if isinstance(options_raw, list) else []
1761
+ )
1762
+
1763
+ lines.append(f"**{title}**")
1764
+ if prompt:
1765
+ lines.append(prompt)
1766
+
1767
+ for idx, opt in enumerate(options, start=1):
1768
+ if not isinstance(opt, dict):
1769
+ continue
1770
+ label = (opt.get("label") or f"Option {idx}").strip()
1771
+ desc = (opt.get("description") or "").strip()
1772
+ if q_idx == 0:
1773
+ option_labels.append(label)
1774
+ if desc:
1775
+ lines.append(f"{idx}. *{label}* - {desc}")
1776
+ else:
1777
+ lines.append(f"{idx}. *{label}*")
1778
+
1779
+ if q_idx < len(qlist) - 1:
1780
+ lines.append("")
1781
+
1782
+ first_q = qlist[0] if qlist and isinstance(qlist[0], dict) else {}
1783
+ multiple = bool(first_q.get("multiple"))
1784
+ text = "\n".join(lines)
1785
+ logger.info(
1786
+ "Question prompt built for %s: len=%s preview=%r",
1787
+ session_id,
1788
+ len(text),
1789
+ text[:200],
1790
+ )
1791
+
1792
+ logger.info(
1793
+ "Question prompt data for %s: qlist=%s options=%s question_id=%s call_id=%s",
1794
+ session_id,
1795
+ len(qlist),
1796
+ len(option_labels),
1797
+ question_id,
1798
+ part.get("callID"),
1799
+ )
1800
+
1801
+ # question_id was resolved via /question listing above when possible.
1802
+ if not option_labels:
1803
+ logger.warning(
1804
+ "Question toolcall had no options in tool_input; session=%s question_id=%s",
1805
+ session_id,
1806
+ question_id,
1807
+ )
1808
+
1809
+ question_count = len(qlist) if qlist else 1
1810
+ multiple = bool(first_q.get("multiple"))
1811
+
1812
+ pending_payload = {
1813
+ "session_id": session_id,
1814
+ "directory": request.working_path,
1815
+ "question_id": question_id,
1816
+ "call_id": part.get("callID"),
1817
+ "message_id": message_id,
1818
+ "prompt_message_id": None,
1819
+ "prompt_text": text,
1820
+ "option_labels": option_labels,
1821
+ "question_count": question_count,
1822
+ "multiple": multiple,
1823
+ "questions": qlist,
1824
+ "thread_id": request.context.thread_id,
1825
+ }
1826
+ self._pending_questions[request.base_session_id] = pending_payload
1827
+
1828
+ if multiple or question_count != 1 or len(option_labels) > 10:
1829
+ # Multi-select or multi-question: show full text + modal button
1830
+ modal_keyboard = None
1831
+ if hasattr(self.im_client, "send_message_with_buttons"):
1832
+ from modules.im import InlineButton, InlineKeyboard
1833
+
1834
+ modal_keyboard = InlineKeyboard(
1835
+ buttons=[[InlineButton(text="Choose…", callback_data="opencode_question:open_modal")]]
1836
+ )
1837
+
1838
+ if modal_keyboard:
1839
+ try:
1840
+ logger.info(
1841
+ "Sending modal open button for %s (multiple=%s questions=%s)",
1842
+ session_id,
1843
+ multiple,
1844
+ question_count,
1845
+ )
1846
+ question_message_id = await self.im_client.send_message_with_buttons(
1847
+ request.context,
1848
+ text,
1849
+ modal_keyboard,
1850
+ parse_mode="markdown",
1851
+ )
1852
+ if question_message_id:
1853
+ pending_payload["prompt_message_id"] = question_message_id
1854
+ return
1855
+ except Exception as err:
1856
+ logger.warning(
1857
+ f"Failed to send modal button, falling back to text: {err}",
1858
+ exc_info=True,
1859
+ )
1860
+
1861
+ await self.im_client.send_message(
1862
+ request.context,
1863
+ text,
1864
+ parse_mode="markdown",
1865
+ )
1866
+ return
1867
+
1868
+ # single question + single select + <=10 options -> buttons
1869
+ if (
1870
+ question_count == 1
1871
+ and isinstance(first_q, dict)
1872
+ and not multiple
1873
+ and len(option_labels) <= 10
1874
+ and hasattr(self.im_client, "send_message_with_buttons")
1875
+ ):
1876
+ from modules.im import InlineButton, InlineKeyboard
1877
+
1878
+ buttons: list[list[InlineButton]] = []
1879
+ row: list[InlineButton] = []
1880
+ for idx, label in enumerate(option_labels, start=1):
1881
+ callback = f"opencode_question:choose:{idx}"
1882
+ row.append(InlineButton(text=label, callback_data=callback))
1883
+ if len(row) == 5:
1884
+ buttons.append(row)
1885
+ row = []
1886
+ if row:
1887
+ buttons.append(row)
1888
+
1889
+ keyboard = InlineKeyboard(buttons=buttons)
1890
+ try:
1891
+ logger.info(
1892
+ "Sending single-select buttons for %s (options=%s)",
1893
+ session_id,
1894
+ len(option_labels),
1895
+ )
1896
+ question_message_id = await self.im_client.send_message_with_buttons(
1897
+ request.context,
1898
+ text,
1899
+ keyboard,
1900
+ parse_mode="markdown",
1901
+ )
1902
+ if question_message_id:
1903
+ pending_payload["prompt_message_id"] = question_message_id
1904
+ return
1905
+ except Exception as err:
1906
+ logger.warning(
1907
+ f"Failed to send Slack buttons, falling back to text: {err}",
1908
+ exc_info=True,
1909
+ )
1910
+
1911
+ # fallback: text-only
1912
+ try:
1913
+ await self.im_client.send_message(
1914
+ request.context,
1915
+ text,
1916
+ parse_mode="markdown",
1917
+ )
1918
+ except Exception as err:
1919
+ logger.error(
1920
+ f"Failed to send question prompt to Slack: {err}",
1921
+ exc_info=True,
1922
+ )
1923
+ return
1924
+
1925
+ toolcall = self.im_client.formatter.format_toolcall(
1926
+ tool_name,
1927
+ tool_input,
1928
+ get_relative_path=_relative_path,
1929
+ )
1930
+ await self.controller.emit_agent_message(
1931
+ request.context,
1932
+ "toolcall",
1933
+ toolcall,
1934
+ parse_mode="markdown",
1935
+ )
1936
+ seen_tool_calls.add(call_key)
1937
+
1938
+ if (
1939
+ info.get("time", {}).get("completed")
1940
+ and message_id not in emitted_assistant_messages
1941
+ and info.get("finish") == "tool-calls"
1942
+ ):
1943
+ text = self._extract_response_text(message)
1944
+ if text:
1945
+ await self.controller.emit_agent_message(
1946
+ request.context,
1947
+ "assistant",
1948
+ text,
1949
+ parse_mode="markdown",
1950
+ )
1951
+ emitted_assistant_messages.add(message_id)
1952
+
1953
+ if messages:
1954
+ last_message = messages[-1]
1955
+ last_info = last_message.get("info", {})
1956
+ last_id = last_info.get("id")
1957
+
1958
+ # Check for error in completed message
1959
+ if (
1960
+ last_id
1961
+ and last_id not in baseline_message_ids
1962
+ and last_info.get("role") == "assistant"
1963
+ and last_info.get("time", {}).get("completed")
1964
+ ):
1965
+ msg_error = last_info.get("error")
1966
+ if msg_error and last_id != last_error_message_id:
1967
+ # New error detected
1968
+ last_error_message_id = last_id
1969
+ error_name = msg_error.get("name", "UnknownError")
1970
+ error_data = msg_error.get("data", {})
1971
+ error_msg = error_data.get("message", "") if isinstance(error_data, dict) else str(error_data)
1972
+
1973
+ logger.warning(
1974
+ "OpenCode message error detected for %s: %s - %s (retry %d/%d)",
1975
+ session_id,
1976
+ error_name,
1977
+ error_msg[:200],
1978
+ error_retry_count,
1979
+ error_retry_limit,
1980
+ )
1981
+
1982
+ if error_retry_count < error_retry_limit:
1983
+ error_retry_count += 1
1984
+ logger.info(
1985
+ "Auto-retrying OpenCode session %s with 'continue' (attempt %d/%d)",
1986
+ session_id,
1987
+ error_retry_count,
1988
+ error_retry_limit,
1989
+ )
1990
+
1991
+ # Send "continue" to retry
1992
+ try:
1993
+ await server.prompt_async(
1994
+ session_id=session_id,
1995
+ directory=request.working_path,
1996
+ text="continue",
1997
+ agent=agent_to_use,
1998
+ model=model_dict,
1999
+ reasoning_effort=reasoning_effort,
2000
+ )
2001
+ # Continue polling for new messages
2002
+ await asyncio.sleep(poll_interval_seconds)
2003
+ continue
2004
+ except Exception as retry_err:
2005
+ logger.error(
2006
+ "Failed to send retry 'continue' for %s: %s",
2007
+ session_id,
2008
+ retry_err,
2009
+ )
2010
+ # Fall through to report error
2011
+
2012
+ # Retry limit reached or retry failed, report error to user
2013
+ await self.controller.emit_agent_message(
2014
+ request.context,
2015
+ "notify",
2016
+ f"OpenCode error: {error_name} - {error_msg[:500]}",
2017
+ )
2018
+ final_text = None
2019
+ break
2020
+
2021
+ # No error, check for normal completion
2022
+ if last_info.get("finish") != "tool-calls":
2023
+ # Reset retry count on successful non-error message
2024
+ if not msg_error:
2025
+ error_retry_count = 0
2026
+ final_text = self._extract_response_text(last_message)
2027
+ break
2028
+
2029
+ await asyncio.sleep(poll_interval_seconds)
2030
+
2031
+ if final_text:
2032
+ await self.emit_result_message(
2033
+ request.context,
2034
+ final_text,
2035
+ subtype="success",
2036
+ started_at=request.started_at,
2037
+ parse_mode="markdown",
2038
+ )
2039
+ else:
2040
+ await self.emit_result_message(
2041
+ request.context,
2042
+ "(No response from OpenCode)",
2043
+ subtype="warning",
2044
+ started_at=request.started_at,
2045
+ )
2046
+
2047
+ except asyncio.CancelledError:
2048
+ logger.info(f"OpenCode request cancelled for {request.base_session_id}")
2049
+ raise
2050
+ except Exception as e:
2051
+ error_name = type(e).__name__
2052
+ error_details = str(e).strip()
2053
+ error_text = f"{error_name}: {error_details}" if error_details else error_name
2054
+
2055
+ logger.error(f"OpenCode request failed: {error_text}", exc_info=True)
2056
+ try:
2057
+ await server.abort_session(session_id, request.working_path)
2058
+ except Exception as abort_err:
2059
+ logger.warning(
2060
+ f"Failed to abort OpenCode session after error: {abort_err}"
2061
+ )
2062
+
2063
+ await self.controller.emit_agent_message(
2064
+ request.context,
2065
+ "notify",
2066
+ f"OpenCode request failed: {error_text}",
2067
+ )
2068
+
2069
+ def _extract_response_text(self, response: Dict[str, Any]) -> str:
2070
+ parts = response.get("parts", [])
2071
+ text_parts = []
2072
+
2073
+ for part in parts:
2074
+ part_type = part.get("type")
2075
+ if part_type == "text":
2076
+ text = part.get("text", "")
2077
+ if text:
2078
+ text_parts.append(text)
2079
+
2080
+ if not text_parts and parts:
2081
+ part_types = [p.get("type") for p in parts]
2082
+ logger.debug(f"OpenCode response has no text parts; part types: {part_types}")
2083
+
2084
+ return "\n\n".join(text_parts).strip()
2085
+
2086
+ def _to_relative_path(self, abs_path: str, cwd: str) -> str:
2087
+ """Convert absolute file paths to relative paths under cwd."""
2088
+ try:
2089
+ abs_path = os.path.abspath(os.path.expanduser(abs_path))
2090
+ cwd = os.path.abspath(os.path.expanduser(cwd))
2091
+ rel_path = os.path.relpath(abs_path, cwd)
2092
+ if rel_path.startswith("../.."):
2093
+ return abs_path
2094
+ if not rel_path.startswith(".") and rel_path != ".":
2095
+ rel_path = "./" + rel_path
2096
+ return rel_path
2097
+ except Exception:
2098
+ return abs_path
2099
+
2100
+ async def handle_stop(self, request: AgentRequest) -> bool:
2101
+
2102
+
2103
+ task = self._active_requests.get(request.base_session_id)
2104
+ if not task or task.done():
2105
+ return False
2106
+
2107
+ req_info = self._request_sessions.get(request.base_session_id)
2108
+ if req_info:
2109
+ try:
2110
+ server = await self._get_server()
2111
+ await server.abort_session(req_info[0], req_info[1])
2112
+ except Exception as e:
2113
+ logger.warning(f"Failed to abort OpenCode session: {e}")
2114
+
2115
+ task.cancel()
2116
+ try:
2117
+ await task
2118
+ except asyncio.CancelledError:
2119
+ pass
2120
+
2121
+ await self.controller.emit_agent_message(
2122
+ request.context, "notify", "Terminated OpenCode execution."
2123
+ )
2124
+ logger.info(f"OpenCode session {request.base_session_id} terminated via /stop")
2125
+ return True
2126
+
2127
+ async def clear_sessions(self, settings_key: str) -> int:
2128
+ self.settings_manager.clear_agent_sessions(settings_key, self.name)
2129
+ terminated = 0
2130
+ for base_id, task in list(self._active_requests.items()):
2131
+ req_info = self._request_sessions.get(base_id)
2132
+ if req_info and len(req_info) >= 3 and req_info[2] == settings_key:
2133
+ if not task.done():
2134
+ try:
2135
+ server = await self._get_server()
2136
+ await server.abort_session(req_info[0], req_info[1])
2137
+ except Exception:
2138
+ pass
2139
+ task.cancel()
2140
+ try:
2141
+ await task
2142
+ except asyncio.CancelledError:
2143
+ pass
2144
+ terminated += 1
2145
+ return terminated
2146
+
2147
+ async def _delete_ack(self, request: AgentRequest):
2148
+ ack_id = request.ack_message_id
2149
+ if ack_id and hasattr(self.im_client, "delete_message"):
2150
+ try:
2151
+ await self.im_client.delete_message(request.context.channel_id, ack_id)
2152
+ except Exception as err:
2153
+ logger.debug(f"Could not delete ack message: {err}")
2154
+ finally:
2155
+ request.ack_message_id = None