telegram-opencode-bridge-bot 0.1.0__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.
- handlers/__init__.py +1 -0
- handlers/commands.py +943 -0
- handlers/messages.py +482 -0
- opencode/__init__.py +8 -0
- opencode/client.py +443 -0
- opencode/server.py +144 -0
- sessions/__init__.py +1 -0
- sessions/manager.py +342 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/METADATA +156 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/RECORD +16 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/WHEEL +5 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/entry_points.txt +2 -0
- telegram_opencode_bridge_bot-0.1.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +1 -0
- utils/formatting.py +218 -0
- utils/security.py +115 -0
opencode/client.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Async HTTP client for the OpenCode serve API.
|
|
2
|
+
|
|
3
|
+
The OpenCode CLI exposes a REST API via ``opencode serve`` (default
|
|
4
|
+
``http://localhost:4096``). This module wraps every documented endpoint
|
|
5
|
+
with an ergonomic, fully-async interface built on :pymod:`aiohttp`.
|
|
6
|
+
|
|
7
|
+
Endpoints covered
|
|
8
|
+
-----------------
|
|
9
|
+
* ``GET /session`` — list all sessions
|
|
10
|
+
* ``POST /session`` — create a new session
|
|
11
|
+
* ``POST /session/{id}/message`` — send a prompt / message
|
|
12
|
+
* ``POST /session/{id}/share`` — share a session (get public URL)
|
|
13
|
+
|
|
14
|
+
Authentication is HTTP Basic Auth (username + password).
|
|
15
|
+
|
|
16
|
+
Usage example::
|
|
17
|
+
|
|
18
|
+
async with OpenCodeClient(username="u", password="p") as client:
|
|
19
|
+
session = await client.create_session()
|
|
20
|
+
reply = await client.send_message(session["id"], "Hello!")
|
|
21
|
+
print(reply.content)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
import aiohttp
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Data models
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class OpenCodeMessage:
|
|
42
|
+
"""Structured representation of a response message from OpenCode.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
role: The role of the message author (e.g. ``"assistant"``).
|
|
46
|
+
content: The textual content of the response.
|
|
47
|
+
session_id: The ID of the session this message belongs to.
|
|
48
|
+
tool_calls: Optional list of tool-call descriptors returned by the
|
|
49
|
+
model. Each entry is a free-form dict whose shape depends on
|
|
50
|
+
the underlying LLM provider.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
role: str
|
|
54
|
+
content: str
|
|
55
|
+
session_id: str
|
|
56
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Exceptions
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OpenCodeError(Exception):
|
|
65
|
+
"""Base exception for OpenCode client errors."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OpenCodeConnectionError(OpenCodeError):
|
|
69
|
+
"""Raised when the server cannot be reached after all retries."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OpenCodeAPIError(OpenCodeError):
|
|
73
|
+
"""Raised when the API returns an unexpected error status."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, status: int, message: str) -> None:
|
|
76
|
+
self.status = status
|
|
77
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Client
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OpenCodeClient:
|
|
86
|
+
"""Async HTTP client for the OpenCode serve API.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
server_url: Base URL of the running ``opencode serve`` instance.
|
|
90
|
+
username: HTTP Basic Auth username (leave empty to skip auth).
|
|
91
|
+
password: HTTP Basic Auth password.
|
|
92
|
+
timeout: Total request timeout in **seconds** (default 300 – five
|
|
93
|
+
minutes, long enough for LLM responses).
|
|
94
|
+
max_retries: Number of attempts for retryable failures (5xx, network
|
|
95
|
+
errors, 429 rate limits).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
server_url: str = "http://localhost:4096",
|
|
101
|
+
username: str = "",
|
|
102
|
+
password: str = "",
|
|
103
|
+
timeout: int = 300,
|
|
104
|
+
max_retries: int = 3,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.server_url: str = server_url.rstrip("/")
|
|
107
|
+
# Set total to None to disable the timeout in aiohttp if timeout is 0 or None
|
|
108
|
+
total_timeout = timeout if timeout and timeout > 0 else None
|
|
109
|
+
self.timeout: aiohttp.ClientTimeout = aiohttp.ClientTimeout(total=total_timeout)
|
|
110
|
+
self.max_retries: int = max_retries
|
|
111
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
112
|
+
|
|
113
|
+
# Build Basic Auth header only when credentials are provided.
|
|
114
|
+
self._auth: Optional[aiohttp.BasicAuth] = None
|
|
115
|
+
if username and password:
|
|
116
|
+
self._auth = aiohttp.BasicAuth(username, password)
|
|
117
|
+
|
|
118
|
+
# -- async context-manager support --------------------------------------
|
|
119
|
+
|
|
120
|
+
async def __aenter__(self) -> "OpenCodeClient":
|
|
121
|
+
"""Allow ``async with OpenCodeClient(...) as client:`` usage."""
|
|
122
|
+
await self._get_session()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
126
|
+
await self.close()
|
|
127
|
+
|
|
128
|
+
# -- internal helpers ---------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
131
|
+
"""Return the shared :class:`aiohttp.ClientSession`, creating it lazily."""
|
|
132
|
+
if self._session is None or self._session.closed:
|
|
133
|
+
self._session = aiohttp.ClientSession(
|
|
134
|
+
timeout=self.timeout,
|
|
135
|
+
auth=self._auth,
|
|
136
|
+
)
|
|
137
|
+
return self._session
|
|
138
|
+
|
|
139
|
+
async def close(self) -> None:
|
|
140
|
+
"""Gracefully close the underlying HTTP session.
|
|
141
|
+
|
|
142
|
+
Safe to call multiple times.
|
|
143
|
+
"""
|
|
144
|
+
if self._session and not self._session.closed:
|
|
145
|
+
await self._session.close()
|
|
146
|
+
logger.debug("HTTP session closed.")
|
|
147
|
+
|
|
148
|
+
async def _request(
|
|
149
|
+
self,
|
|
150
|
+
method: str,
|
|
151
|
+
endpoint: str,
|
|
152
|
+
*,
|
|
153
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
154
|
+
params: Optional[Dict[str, str]] = None,
|
|
155
|
+
) -> Any:
|
|
156
|
+
"""Execute an HTTP request with automatic retries.
|
|
157
|
+
|
|
158
|
+
Retries are performed for:
|
|
159
|
+
* HTTP 429 (rate-limited) — honours ``Retry-After`` header.
|
|
160
|
+
* HTTP 5xx (server errors) — exponential back-off.
|
|
161
|
+
* Network-level ``aiohttp.ClientError`` — exponential back-off.
|
|
162
|
+
|
|
163
|
+
Client errors (4xx other than 429) are raised immediately.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Parsed JSON body (``dict`` or ``list``) when the response has a
|
|
167
|
+
JSON content-type, otherwise a ``dict`` with ``content`` (text)
|
|
168
|
+
and ``status`` keys.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
OpenCodeAPIError: For non-retryable HTTP errors.
|
|
172
|
+
OpenCodeConnectionError: After all retries are exhausted.
|
|
173
|
+
"""
|
|
174
|
+
url = f"{self.server_url}/{endpoint.lstrip('/')}"
|
|
175
|
+
session = await self._get_session()
|
|
176
|
+
|
|
177
|
+
last_error: Optional[Exception] = None
|
|
178
|
+
|
|
179
|
+
for attempt in range(1, self.max_retries + 1):
|
|
180
|
+
try:
|
|
181
|
+
logger.debug("[Attempt %d/%d] %s %s", attempt, self.max_retries, method, url)
|
|
182
|
+
|
|
183
|
+
async with session.request(
|
|
184
|
+
method,
|
|
185
|
+
url,
|
|
186
|
+
json=json_data,
|
|
187
|
+
params=params,
|
|
188
|
+
) as resp:
|
|
189
|
+
# --- rate limiting ------------------------------------
|
|
190
|
+
if resp.status == 429:
|
|
191
|
+
retry_after = int(resp.headers.get("Retry-After", "5"))
|
|
192
|
+
logger.warning(
|
|
193
|
+
"Rate-limited (429). Retrying in %ds (attempt %d/%d).",
|
|
194
|
+
retry_after,
|
|
195
|
+
attempt,
|
|
196
|
+
self.max_retries,
|
|
197
|
+
)
|
|
198
|
+
await asyncio.sleep(retry_after)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# --- raise on error -----------------------------------
|
|
202
|
+
if resp.status >= 400:
|
|
203
|
+
body = await resp.text()
|
|
204
|
+
if resp.status < 500:
|
|
205
|
+
# Client error — do NOT retry.
|
|
206
|
+
raise OpenCodeAPIError(resp.status, body)
|
|
207
|
+
# Server error — let retry logic handle it.
|
|
208
|
+
raise aiohttp.ClientResponseError(
|
|
209
|
+
request_info=resp.request_info,
|
|
210
|
+
history=resp.history,
|
|
211
|
+
status=resp.status,
|
|
212
|
+
message=body,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# --- parse success body --------------------------------
|
|
216
|
+
content_type = resp.content_type or ""
|
|
217
|
+
if "json" in content_type:
|
|
218
|
+
return await resp.json()
|
|
219
|
+
text = await resp.text()
|
|
220
|
+
return {"content": text, "status": resp.status}
|
|
221
|
+
|
|
222
|
+
except OpenCodeAPIError:
|
|
223
|
+
raise # never retry 4xx
|
|
224
|
+
|
|
225
|
+
except aiohttp.ClientResponseError as exc:
|
|
226
|
+
last_error = exc
|
|
227
|
+
logger.error("Server error %d: %s", exc.status, exc.message)
|
|
228
|
+
|
|
229
|
+
except aiohttp.ClientError as exc:
|
|
230
|
+
last_error = exc
|
|
231
|
+
logger.error("Connection error: %s", exc)
|
|
232
|
+
|
|
233
|
+
# Exponential back-off before next attempt.
|
|
234
|
+
if attempt < self.max_retries:
|
|
235
|
+
wait = min(2 ** attempt, 30)
|
|
236
|
+
logger.info("Retrying in %ds …", wait)
|
|
237
|
+
await asyncio.sleep(wait)
|
|
238
|
+
|
|
239
|
+
raise OpenCodeConnectionError(
|
|
240
|
+
f"Failed after {self.max_retries} retries: {last_error}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# -- public API ---------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async def is_available(self) -> bool:
|
|
246
|
+
"""Check whether the OpenCode server is reachable.
|
|
247
|
+
|
|
248
|
+
Returns ``True`` if a request to the sessions endpoint succeeds
|
|
249
|
+
(status < 500), ``False`` otherwise. Does **not** raise.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
session = await self._get_session()
|
|
253
|
+
async with session.get(f"{self.server_url}/session") as resp:
|
|
254
|
+
reachable = resp.status < 500
|
|
255
|
+
logger.debug("Server availability check: %s (status %d)", reachable, resp.status)
|
|
256
|
+
return reachable
|
|
257
|
+
except Exception as exc: # noqa: BLE001
|
|
258
|
+
logger.debug("Server unreachable: %s", exc)
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
async def list_sessions(self) -> List[Dict[str, Any]]:
|
|
262
|
+
"""List all existing OpenCode sessions.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A list of session dicts. The exact keys depend on the OpenCode
|
|
266
|
+
version, but ``id`` is always present.
|
|
267
|
+
"""
|
|
268
|
+
result = await self._request("GET", "/session")
|
|
269
|
+
if isinstance(result, list):
|
|
270
|
+
return result
|
|
271
|
+
# Some API versions wrap the list in an object.
|
|
272
|
+
if isinstance(result, dict):
|
|
273
|
+
return result.get("sessions", result.get("data", []))
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
async def create_session(self, directory: Optional[str] = None) -> Dict[str, Any]:
|
|
277
|
+
"""Create a new OpenCode session.
|
|
278
|
+
|
|
279
|
+
Parameters:
|
|
280
|
+
directory: Optional workspace/working directory for the session.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
A dict describing the newly created session. The ``id`` key
|
|
284
|
+
contains the session identifier needed for subsequent calls.
|
|
285
|
+
"""
|
|
286
|
+
params = {}
|
|
287
|
+
if directory:
|
|
288
|
+
params["directory"] = directory
|
|
289
|
+
|
|
290
|
+
result = await self._request("POST", "/session", params=params)
|
|
291
|
+
logger.info("Created new session: %s", result.get("id", "<unknown>") if isinstance(result, dict) else result)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
async def send_message(
|
|
295
|
+
self,
|
|
296
|
+
session_id: str,
|
|
297
|
+
content: str,
|
|
298
|
+
model: Optional[str] = None,
|
|
299
|
+
) -> Optional[OpenCodeMessage]:
|
|
300
|
+
"""Send a prompt to an OpenCode session and return the response.
|
|
301
|
+
|
|
302
|
+
Parameters:
|
|
303
|
+
session_id: The target session identifier.
|
|
304
|
+
content: The user-facing prompt text.
|
|
305
|
+
model: Optional model identifier (e.g. "provider/model").
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
An :class:`OpenCodeMessage` containing the assistant's reply.
|
|
309
|
+
"""
|
|
310
|
+
payload = {
|
|
311
|
+
"parts": [
|
|
312
|
+
{
|
|
313
|
+
"type": "text",
|
|
314
|
+
"text": content,
|
|
315
|
+
}
|
|
316
|
+
]
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if model:
|
|
320
|
+
if "/" in model:
|
|
321
|
+
provider_id, model_id = model.split("/", 1)
|
|
322
|
+
payload["model"] = {
|
|
323
|
+
"providerID": provider_id.strip(),
|
|
324
|
+
"modelID": model_id.strip(),
|
|
325
|
+
}
|
|
326
|
+
else:
|
|
327
|
+
payload["model"] = {
|
|
328
|
+
"modelID": model.strip(),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
result = await self._request(
|
|
332
|
+
"POST",
|
|
333
|
+
f"/session/{session_id}/message",
|
|
334
|
+
json_data=payload,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
logger.info("Raw OpenCode API response: %s", result)
|
|
338
|
+
|
|
339
|
+
if result is None:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
if isinstance(result, dict):
|
|
343
|
+
# Check for abort/cancel/interrupt finish reasons
|
|
344
|
+
info = result.get("info", {})
|
|
345
|
+
finish_reason = ""
|
|
346
|
+
if isinstance(info, dict):
|
|
347
|
+
finish_reason = info.get("finish", "")
|
|
348
|
+
if not finish_reason:
|
|
349
|
+
finish_reason = result.get("finish", "")
|
|
350
|
+
|
|
351
|
+
if str(finish_reason).lower() in ("abort", "aborted", "cancel", "cancelled", "interrupt", "interrupted"):
|
|
352
|
+
return OpenCodeMessage(
|
|
353
|
+
role="assistant",
|
|
354
|
+
content="ABORTED",
|
|
355
|
+
session_id=session_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Extract content from the returned parts list if available
|
|
359
|
+
parts = result.get("parts", [])
|
|
360
|
+
content_text = ""
|
|
361
|
+
if isinstance(parts, list):
|
|
362
|
+
text_parts = [
|
|
363
|
+
p.get("text", "")
|
|
364
|
+
for p in parts
|
|
365
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
366
|
+
]
|
|
367
|
+
content_text = "".join(text_parts)
|
|
368
|
+
|
|
369
|
+
# Fallback to standard response text fields if parts are absent/empty
|
|
370
|
+
if not content_text:
|
|
371
|
+
content_text = result.get(
|
|
372
|
+
"content",
|
|
373
|
+
result.get("text", result.get("message", "")),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return OpenCodeMessage(
|
|
377
|
+
role=result.get("role", "assistant"),
|
|
378
|
+
content=content_text,
|
|
379
|
+
session_id=session_id,
|
|
380
|
+
tool_calls=result.get("tool_calls", result.get("toolCalls", [])),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Fallback for unexpected shapes.
|
|
384
|
+
return OpenCodeMessage(
|
|
385
|
+
role="assistant",
|
|
386
|
+
content="",
|
|
387
|
+
session_id=session_id,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
async def share_session(self, session_id: str) -> str:
|
|
391
|
+
"""Share a session and retrieve its public URL.
|
|
392
|
+
|
|
393
|
+
Parameters:
|
|
394
|
+
session_id: The session to share.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
The publicly accessible URL for the shared session.
|
|
398
|
+
"""
|
|
399
|
+
result = await self._request(
|
|
400
|
+
"POST",
|
|
401
|
+
f"/session/{session_id}/share",
|
|
402
|
+
)
|
|
403
|
+
url: str = result.get("url", result.get("share_url", str(result)))
|
|
404
|
+
logger.info("Session %s shared: %s", session_id, url)
|
|
405
|
+
return url
|
|
406
|
+
|
|
407
|
+
async def get_available_models(self) -> Dict[str, Any]:
|
|
408
|
+
"""Fetch all available models and providers from the server."""
|
|
409
|
+
session = await self._get_session()
|
|
410
|
+
url = f"{self.server_url}/provider"
|
|
411
|
+
headers = {"Accept": "application/json"}
|
|
412
|
+
|
|
413
|
+
async with session.get(url, headers=headers) as resp:
|
|
414
|
+
if resp.status >= 400:
|
|
415
|
+
body = await resp.text()
|
|
416
|
+
raise OpenCodeAPIError(resp.status, body)
|
|
417
|
+
|
|
418
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
419
|
+
if "json" in content_type:
|
|
420
|
+
return await resp.json()
|
|
421
|
+
|
|
422
|
+
text = await resp.text()
|
|
423
|
+
try:
|
|
424
|
+
import json
|
|
425
|
+
return json.loads(text)
|
|
426
|
+
except ValueError:
|
|
427
|
+
return {}
|
|
428
|
+
|
|
429
|
+
async def abort_session(self, session_id: str) -> bool:
|
|
430
|
+
"""Send an abort signal to stop active model processing in a session."""
|
|
431
|
+
try:
|
|
432
|
+
result = await self._request("POST", f"/session/{session_id}/abort")
|
|
433
|
+
# If the response indicates success, or we get a successful status code, return True
|
|
434
|
+
if isinstance(result, dict):
|
|
435
|
+
return result.get("success", True)
|
|
436
|
+
return True
|
|
437
|
+
except OpenCodeAPIError as e:
|
|
438
|
+
# If we get a 400/404, it might mean there is nothing active to abort or session is not found
|
|
439
|
+
logger.warning(f"Abort request returned an API error: {e}")
|
|
440
|
+
return False
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logger.error(f"Unexpected error aborting session {session_id}: {e}")
|
|
443
|
+
raise
|
opencode/server.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""OpenCode server process manager.
|
|
2
|
+
|
|
3
|
+
Handles starting, stopping, and restarting the `opencode serve` process
|
|
4
|
+
deterministically — when the user switches projects via /project, the server
|
|
5
|
+
is restarted from the new project directory so the AI agent is fully scoped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import platform
|
|
14
|
+
import shutil
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_server_process: subprocess.Popen | None = None
|
|
19
|
+
_server_port: int = 8080 # Default port is 8080 from .env
|
|
20
|
+
|
|
21
|
+
def get_opencode_binary() -> str:
|
|
22
|
+
"""Find the opencode binary on the system."""
|
|
23
|
+
path = os.environ.get("OPENCODE_BINARY", "")
|
|
24
|
+
if path and os.path.isfile(path):
|
|
25
|
+
return path
|
|
26
|
+
found = shutil.which("opencode")
|
|
27
|
+
if found:
|
|
28
|
+
return found
|
|
29
|
+
return "opencode"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def restart_server(directory: str, port: int = 8080, hostname: str = "127.0.0.1") -> bool:
|
|
33
|
+
"""Stop the running opencode serve process and restart it from *directory*.
|
|
34
|
+
|
|
35
|
+
Returns True if the server came up successfully, False otherwise.
|
|
36
|
+
"""
|
|
37
|
+
global _server_process, _server_port
|
|
38
|
+
_server_port = port
|
|
39
|
+
|
|
40
|
+
# 1. Stop the existing server
|
|
41
|
+
await stop_server()
|
|
42
|
+
await asyncio.sleep(1)
|
|
43
|
+
|
|
44
|
+
# 2. Start a new one from the target directory
|
|
45
|
+
binary = get_opencode_binary()
|
|
46
|
+
cmd = [binary, "serve", "--port", str(port), "--hostname", hostname]
|
|
47
|
+
|
|
48
|
+
logger.info(f"Starting opencode serve from {directory}: {' '.join(cmd)}")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
creationflags = 0
|
|
52
|
+
if platform.system() == "Windows":
|
|
53
|
+
# CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
54
|
+
creationflags = 0x00000200
|
|
55
|
+
|
|
56
|
+
_server_process = subprocess.Popen(
|
|
57
|
+
cmd,
|
|
58
|
+
cwd=directory,
|
|
59
|
+
stdout=subprocess.DEVNULL,
|
|
60
|
+
stderr=subprocess.DEVNULL,
|
|
61
|
+
stdin=subprocess.DEVNULL,
|
|
62
|
+
creationflags=creationflags,
|
|
63
|
+
start_new_session=(platform.system() != "Windows"),
|
|
64
|
+
)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Failed to start opencode serve: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# 3. Wait until the server is reachable (max 30s)
|
|
70
|
+
import aiohttp
|
|
71
|
+
for attempt in range(30):
|
|
72
|
+
await asyncio.sleep(1)
|
|
73
|
+
try:
|
|
74
|
+
async with aiohttp.ClientSession() as session:
|
|
75
|
+
async with session.get(f"http://{hostname}:{port}/session", timeout=aiohttp.ClientTimeout(total=2)) as resp:
|
|
76
|
+
if resp.status < 500:
|
|
77
|
+
logger.info(f"opencode serve is up after {attempt + 1}s (pid={_server_process.pid})")
|
|
78
|
+
return True
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
logger.error("opencode serve did not become reachable within 30s")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def stop_server() -> None:
|
|
87
|
+
"""Stop the running opencode serve process (if any)."""
|
|
88
|
+
global _server_process
|
|
89
|
+
|
|
90
|
+
if _server_process is not None and _server_process.poll() is None:
|
|
91
|
+
logger.info(f"Stopping opencode serve (pid={_server_process.pid})")
|
|
92
|
+
try:
|
|
93
|
+
_server_process.terminate()
|
|
94
|
+
try:
|
|
95
|
+
_server_process.wait(timeout=5)
|
|
96
|
+
except subprocess.TimeoutExpired:
|
|
97
|
+
_server_process.kill()
|
|
98
|
+
_server_process.wait(timeout=3)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning(f"Error stopping opencode serve: {e}")
|
|
101
|
+
_server_process = None
|
|
102
|
+
|
|
103
|
+
# Also kill any stray opencode serve processes on our port
|
|
104
|
+
if platform.system() == "Windows":
|
|
105
|
+
try:
|
|
106
|
+
# Query netstat to find process ID listening on the port
|
|
107
|
+
cmd = f'netstat -ano | findstr LISTENING | findstr :{_server_port}'
|
|
108
|
+
proc = await asyncio.create_subprocess_shell(
|
|
109
|
+
cmd,
|
|
110
|
+
stdout=asyncio.subprocess.PIPE,
|
|
111
|
+
stderr=asyncio.subprocess.DEVNULL
|
|
112
|
+
)
|
|
113
|
+
stdout, _ = await proc.communicate()
|
|
114
|
+
lines = stdout.decode().strip().split('\n')
|
|
115
|
+
for line in lines:
|
|
116
|
+
parts = line.strip().split()
|
|
117
|
+
if len(parts) >= 5:
|
|
118
|
+
pid = parts[-1]
|
|
119
|
+
if pid.isdigit() and int(pid) > 0:
|
|
120
|
+
os.system(f"taskkill /F /T /PID {pid}")
|
|
121
|
+
logger.info(f"Killed stray Windows PID={pid} on port {_server_port}")
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning(f"Failed to kill stray Windows process: {e}")
|
|
124
|
+
else:
|
|
125
|
+
# Unix lsof implementation
|
|
126
|
+
try:
|
|
127
|
+
proc = await asyncio.create_subprocess_exec(
|
|
128
|
+
"lsof", "-ti", f":{_server_port}", "-sTCP:LISTEN",
|
|
129
|
+
stdout=asyncio.subprocess.PIPE,
|
|
130
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
131
|
+
)
|
|
132
|
+
stdout, _ = await proc.communicate()
|
|
133
|
+
pids = stdout.decode().strip().split()
|
|
134
|
+
for pid in pids:
|
|
135
|
+
if pid.isdigit():
|
|
136
|
+
try:
|
|
137
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
138
|
+
logger.info(f"Killed stray Unix PID={pid} on port {_server_port}")
|
|
139
|
+
except ProcessLookupError:
|
|
140
|
+
pass
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(f"Error calling lsof: {e}")
|
|
143
|
+
|
|
144
|
+
await asyncio.sleep(0.5)
|
sessions/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|