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.
@@ -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())