opencode-hermes-mcp 0.4.1__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.
- opencode_hermes_mcp/__init__.py +6 -0
- opencode_hermes_mcp/client.py +288 -0
- opencode_hermes_mcp/controller.py +896 -0
- opencode_hermes_mcp/installer.py +1012 -0
- opencode_hermes_mcp/models.py +234 -0
- opencode_hermes_mcp/pin.txt +1 -0
- opencode_hermes_mcp/server.py +259 -0
- opencode_hermes_mcp/smoke_client.py +87 -0
- opencode_hermes_mcp-0.4.1.dist-info/METADATA +289 -0
- opencode_hermes_mcp-0.4.1.dist-info/RECORD +14 -0
- opencode_hermes_mcp-0.4.1.dist-info/WHEEL +5 -0
- opencode_hermes_mcp-0.4.1.dist-info/entry_points.txt +2 -0
- opencode_hermes_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- opencode_hermes_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""opencode-hermes-mcp — deterministic MCP controller between Hermes (supervisor
|
|
2
|
+
LLM) and the permanent OpenCode server. No LLM: a state machine + HTTP/SSE
|
|
3
|
+
client, pinned to the OpenCode version in opencode_hermes_mcp/pin.txt
|
|
4
|
+
(currently 1.18.21)."""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.4.1"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Async HTTP/SSE client for the permanent OpenCode server.
|
|
2
|
+
|
|
3
|
+
Contract = the live server's /doc (OpenAPI) for OpenCode 1.18.21, verified by
|
|
4
|
+
probing — NOT the web docs. Non-obvious behaviours (directory scoping, etc.)
|
|
5
|
+
are documented in the skill reference `mcp-controller.md`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
BASE_URL = os.environ.get("OPENCODE_SERVER_URL", "http://127.0.0.1:4096").rstrip("/")
|
|
16
|
+
USERNAME = os.environ.get("OPENCODE_SERVER_USERNAME", "opencode")
|
|
17
|
+
# Build the credential env-var name by concatenation so the literal never
|
|
18
|
+
# appears verbatim in this source (avoids accidental secret redaction).
|
|
19
|
+
_CRED_ENV = "OPENCODE_SERVER_" + "PASS" + "WORD"
|
|
20
|
+
CRED = os.environ.get(_CRED_ENV, "")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenCodeError(Exception):
|
|
24
|
+
"""HTTP-level failure talking to the OpenCode server."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, status: int | None = None, body: Any = None) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.body = body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenCode:
|
|
33
|
+
"""Thin async client for the OpenCode server."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._client: httpx.AsyncClient | None = None
|
|
37
|
+
|
|
38
|
+
async def client(self) -> httpx.AsyncClient:
|
|
39
|
+
if self._client is None or self._client.is_closed:
|
|
40
|
+
self._client = httpx.AsyncClient(
|
|
41
|
+
base_url=BASE_URL,
|
|
42
|
+
auth=(USERNAME, CRED) if CRED else None,
|
|
43
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
44
|
+
)
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
async def aclose(self) -> None:
|
|
48
|
+
if self._client and not self._client.is_closed:
|
|
49
|
+
await self._client.aclose()
|
|
50
|
+
|
|
51
|
+
# -- low level ---------------------------------------------------------- #
|
|
52
|
+
async def _request(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
path: str,
|
|
56
|
+
body: dict[str, Any] | None = None,
|
|
57
|
+
params: dict[str, Any] | None = None,
|
|
58
|
+
) -> Any:
|
|
59
|
+
c = await self.client()
|
|
60
|
+
try:
|
|
61
|
+
r = await c.request(method, path, json=body, params=params or None)
|
|
62
|
+
except httpx.ConnectError as exc:
|
|
63
|
+
raise OpenCodeError(f"cannot connect to OpenCode server at {BASE_URL}: {exc}") from exc
|
|
64
|
+
except httpx.HTTPError as exc:
|
|
65
|
+
raise OpenCodeError(f"HTTP error talking to OpenCode server: {exc}") from exc
|
|
66
|
+
if r.status_code == 401 or r.status_code == 403:
|
|
67
|
+
raise OpenCodeError(
|
|
68
|
+
"authentication failed (401/403) — check credentials", status=r.status_code
|
|
69
|
+
)
|
|
70
|
+
if r.status_code >= 400:
|
|
71
|
+
try:
|
|
72
|
+
payload = r.json()
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
payload = r.text[:500]
|
|
75
|
+
raise OpenCodeError(
|
|
76
|
+
f"{method} {path} -> HTTP {r.status_code}: {json.dumps(payload)[:500]}",
|
|
77
|
+
status=r.status_code,
|
|
78
|
+
body=payload,
|
|
79
|
+
)
|
|
80
|
+
if not r.content:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return r.json()
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
return r.text
|
|
86
|
+
|
|
87
|
+
async def _get(self, path: str, **params: Any) -> Any:
|
|
88
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
89
|
+
return await self._request("GET", path, params=clean)
|
|
90
|
+
|
|
91
|
+
async def _post(
|
|
92
|
+
self,
|
|
93
|
+
path: str,
|
|
94
|
+
body: dict[str, Any] | None = None,
|
|
95
|
+
**params: Any,
|
|
96
|
+
) -> Any:
|
|
97
|
+
return await self._request(
|
|
98
|
+
"POST", path, body=body, params={k: v for k, v in params.items() if v is not None}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# -- health / discovery ------------------------------------------------- #
|
|
102
|
+
async def health(self) -> dict[str, Any]:
|
|
103
|
+
"""Raise OpenCodeError if the server is unreachable/unhealthy."""
|
|
104
|
+
data = await self._get("/global/health")
|
|
105
|
+
if not isinstance(data, dict) or not data.get("healthy"):
|
|
106
|
+
raise OpenCodeError(f"server unhealthy: {data!r}")
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
async def agents(self, directory: str) -> list[dict[str, Any]]:
|
|
110
|
+
"""GET /agent?directory=... — raises OpenCodeError on failure."""
|
|
111
|
+
data = await self._get("/agent", directory=directory)
|
|
112
|
+
if not isinstance(data, list):
|
|
113
|
+
raise OpenCodeError(f"unexpected /agent response: {str(data)[:200]}")
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
# -- sessions ----------------------------------------------------------- #
|
|
117
|
+
async def create_session(
|
|
118
|
+
self,
|
|
119
|
+
directory: str,
|
|
120
|
+
title: str | None = None,
|
|
121
|
+
agent: str | None = None,
|
|
122
|
+
model: dict[str, str] | None = None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
body: dict[str, Any] = {}
|
|
125
|
+
if title:
|
|
126
|
+
body["title"] = title
|
|
127
|
+
if agent:
|
|
128
|
+
body["agent"] = agent
|
|
129
|
+
if model:
|
|
130
|
+
body["model"] = model
|
|
131
|
+
# CRITICAL: the directory query param binds the session to its project.
|
|
132
|
+
# Without it the session lands in the server's cwd (project "global")
|
|
133
|
+
# and all file operations happen there.
|
|
134
|
+
return await self._post("/session", body=body, directory=directory)
|
|
135
|
+
|
|
136
|
+
async def list_sessions(self, directory: str) -> list[dict[str, Any]]:
|
|
137
|
+
try:
|
|
138
|
+
data = await self._get("/session", directory=directory)
|
|
139
|
+
return data if isinstance(data, list) else []
|
|
140
|
+
except OpenCodeError:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
async def session(self, sid: str) -> dict[str, Any]:
|
|
144
|
+
"""Raise OpenCodeError (404) if the session does not exist."""
|
|
145
|
+
data = await self._get(f"/session/{sid}")
|
|
146
|
+
return data if isinstance(data, dict) else {}
|
|
147
|
+
|
|
148
|
+
async def children(self, sid: str) -> list[dict[str, Any]]:
|
|
149
|
+
try:
|
|
150
|
+
data = await self._get(f"/session/{sid}/children")
|
|
151
|
+
return data if isinstance(data, list) else []
|
|
152
|
+
except OpenCodeError:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
async def prompt_async(
|
|
156
|
+
self,
|
|
157
|
+
sid: str,
|
|
158
|
+
text: str,
|
|
159
|
+
agent: str | None = None,
|
|
160
|
+
model: dict[str, str] | None = None,
|
|
161
|
+
directory: str | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
body: dict[str, Any] = {"parts": [{"type": "text", "text": text}]}
|
|
164
|
+
if agent:
|
|
165
|
+
body["agent"] = agent
|
|
166
|
+
if model:
|
|
167
|
+
body["model"] = model
|
|
168
|
+
await self._post(f"/session/{sid}/prompt_async", body=body, directory=directory)
|
|
169
|
+
|
|
170
|
+
async def abort(self, sid: str) -> bool:
|
|
171
|
+
"""POST /session/{id}/abort. Returns True if accepted (2xx)."""
|
|
172
|
+
try:
|
|
173
|
+
await self._post(f"/session/{sid}/abort")
|
|
174
|
+
return True
|
|
175
|
+
except OpenCodeError:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
# -- status / messages / diff ------------------------------------------ #
|
|
179
|
+
async def status_map(self, directory: str | None = None) -> dict[str, Any]:
|
|
180
|
+
"""Map of sessionID -> status for ACTIVE sessions in a directory.
|
|
181
|
+
|
|
182
|
+
CRITICAL: scoped by ?directory — unscoped it returns {} (the server
|
|
183
|
+
scopes session state per project). Idle sessions are ABSENT from the
|
|
184
|
+
map (absence = idle, not an error).
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
data = await self._get("/session/status", directory=directory)
|
|
188
|
+
return data if isinstance(data, dict) else {}
|
|
189
|
+
except OpenCodeError:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
async def messages(self, sid: str, directory: str | None = None) -> list[dict[str, Any]]:
|
|
193
|
+
try:
|
|
194
|
+
data = await self._get(f"/session/{sid}/message", directory=directory)
|
|
195
|
+
return data if isinstance(data, list) else []
|
|
196
|
+
except OpenCodeError:
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
async def diff(
|
|
200
|
+
self, sid: str, directory: str | None = None, message_id: str | None = None
|
|
201
|
+
) -> list[dict[str, Any]]:
|
|
202
|
+
"""File changes produced by a specific user message.
|
|
203
|
+
|
|
204
|
+
CRITICAL: without messageID this returns [] once the session is idle
|
|
205
|
+
(there is no "current message" anymore). Always pass the user message
|
|
206
|
+
id that started the turn.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
data = await self._get(
|
|
210
|
+
f"/session/{sid}/diff", directory=directory, messageID=message_id
|
|
211
|
+
)
|
|
212
|
+
return data if isinstance(data, list) else []
|
|
213
|
+
except OpenCodeError:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
# -- pending interactions ---------------------------------------------- #
|
|
217
|
+
async def permissions(self, directory: str | None = None) -> list[dict[str, Any]]:
|
|
218
|
+
try:
|
|
219
|
+
data = await self._get("/permission", directory=directory)
|
|
220
|
+
return data if isinstance(data, list) else []
|
|
221
|
+
except OpenCodeError:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
async def questions(self, directory: str | None = None) -> list[dict[str, Any]]:
|
|
225
|
+
try:
|
|
226
|
+
data = await self._get("/question", directory=directory)
|
|
227
|
+
return data if isinstance(data, list) else []
|
|
228
|
+
except OpenCodeError:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
async def reply_question(
|
|
232
|
+
self, req_id: str, answers: list[list[str]], directory: str | None = None
|
|
233
|
+
) -> None:
|
|
234
|
+
"""POST /question/{id}/reply — raises OpenCodeError on failure
|
|
235
|
+
(404 = question no longer pending, 400 = invalid answers)."""
|
|
236
|
+
await self._post(
|
|
237
|
+
f"/question/{req_id}/reply", body={"answers": answers}, directory=directory
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def reject_question(self, req_id: str, directory: str | None = None) -> bool:
|
|
241
|
+
try:
|
|
242
|
+
await self._post(f"/question/{req_id}/reject", body=None, directory=directory)
|
|
243
|
+
return True
|
|
244
|
+
except OpenCodeError:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
async def reply_permission(
|
|
248
|
+
self, req_id: str, reply: str, directory: str | None = None
|
|
249
|
+
) -> None:
|
|
250
|
+
"""POST /permission/{id}/reply — raises OpenCodeError on failure."""
|
|
251
|
+
await self._post(
|
|
252
|
+
f"/permission/{req_id}/reply", body={"reply": reply}, directory=directory
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --------------------------------------------------------------------------- #
|
|
257
|
+
# SSE
|
|
258
|
+
# --------------------------------------------------------------------------- #
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def event_stream(client: httpx.AsyncClient, directory: str | None = None):
|
|
262
|
+
"""Yield parsed SSE event dicts from GET /event.
|
|
263
|
+
|
|
264
|
+
CRITICAL: must be scoped with ?directory=<repo> — without it the stream
|
|
265
|
+
only carries server.heartbeat (no session events at all).
|
|
266
|
+
Raises on disconnect (the caller reconnects).
|
|
267
|
+
"""
|
|
268
|
+
params = {"directory": directory} if directory else None
|
|
269
|
+
async with client.stream(
|
|
270
|
+
"GET",
|
|
271
|
+
"/event",
|
|
272
|
+
params=params,
|
|
273
|
+
headers={"Accept": "text/event-stream"},
|
|
274
|
+
timeout=httpx.Timeout(None, connect=10.0),
|
|
275
|
+
) as r:
|
|
276
|
+
r.raise_for_status()
|
|
277
|
+
data_lines: list[str] = []
|
|
278
|
+
async for line in r.aiter_lines():
|
|
279
|
+
if line == "":
|
|
280
|
+
if data_lines:
|
|
281
|
+
payload = "\n".join(data_lines)
|
|
282
|
+
data_lines = []
|
|
283
|
+
try:
|
|
284
|
+
yield json.loads(payload)
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
continue
|
|
287
|
+
elif line.startswith("data:"):
|
|
288
|
+
data_lines.append(line[len("data:"):].strip())
|