opencode-talk-bridge 0.2.7__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,418 @@
1
+ """HTTP + SSE client for a local ``opencode serve`` instance.
2
+
3
+ Verified against the OpenAPI of OpenCode 1.15.11 (``GET /doc``). Routes are
4
+ session-level (not project-scoped) and the permission flow is global:
5
+
6
+ - ``GET /global/health`` -> {healthy, version}
7
+ - ``GET /global/event`` -> SSE stream of GlobalEvent
8
+ - ``POST /session`` -> create a Session
9
+ - ``GET /session`` -> list sessions
10
+ - ``POST /session/{id}/message`` -> blocks; returns {info, parts}
11
+ - ``POST /session/{id}/abort`` -> abort the running turn
12
+ - ``GET /permission`` -> list pending permissions
13
+ - ``POST /permission/{requestID}/reply`` -> {reply: once|always|reject}
14
+
15
+ The prompt endpoint (``POST /session/{id}/message``) is synchronous: it returns
16
+ the assembled assistant message when the turn finishes. While it blocks, the
17
+ agent may ask for permission — those asks arrive on the SSE stream, so the
18
+ bridge runs the prompt in a worker thread and the SSE reader in another.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ from collections.abc import Iterator
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ # Permission reply outcomes accepted by POST /permission/{id}/reply.
32
+ PERMISSION_REPLIES = ("once", "always", "reject")
33
+
34
+ # Sentinel: "use the client's configured directory" vs an explicit per-call one.
35
+ _DEFAULT_DIR = object()
36
+
37
+
38
+ class OpenCodeError(Exception):
39
+ """Base error for OpenCode HTTP interactions."""
40
+
41
+
42
+ class OpenCodeDownError(OpenCodeError):
43
+ """The OpenCode server is unreachable (transport error / failed health)."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class PermissionAsk:
48
+ """A pending permission request surfaced by the SSE stream."""
49
+
50
+ request_id: str
51
+ session_id: str
52
+ permission: str
53
+ patterns: tuple[str, ...]
54
+ tool: dict[str, Any]
55
+
56
+ @classmethod
57
+ def from_request(cls, raw: dict[str, Any]) -> PermissionAsk:
58
+ return cls(
59
+ request_id=raw["id"],
60
+ session_id=raw.get("sessionID", ""),
61
+ permission=raw.get("permission", ""),
62
+ patterns=tuple(raw.get("patterns") or ()),
63
+ tool=raw.get("tool") or {},
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class QuestionOption:
69
+ label: str # also the value sent back in the answer
70
+ description: str = ""
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class QuestionAsk:
75
+ """A pending agent question surfaced by the SSE stream.
76
+
77
+ A question carries one or more sub-questions, each with options and an
78
+ optional free-text ("custom") answer. The bridge handles the common case:
79
+ a single question, rendered as a numbered picker (plus free text if custom).
80
+ The selected option's ``label`` is what gets sent back as the answer.
81
+ """
82
+
83
+ request_id: str
84
+ session_id: str
85
+ question: str
86
+ header: str
87
+ options: tuple[QuestionOption, ...]
88
+ custom: bool
89
+
90
+ @classmethod
91
+ def from_request(cls, raw: dict[str, Any]) -> QuestionAsk:
92
+ questions = raw.get("questions") or [{}]
93
+ first = questions[0] if questions else {}
94
+ options = tuple(
95
+ QuestionOption(label=o.get("label", ""), description=o.get("description", ""))
96
+ for o in (first.get("options") or [])
97
+ if isinstance(o, dict)
98
+ )
99
+ return cls(
100
+ request_id=raw["id"],
101
+ session_id=raw.get("sessionID", ""),
102
+ question=first.get("question", ""),
103
+ header=first.get("header", ""),
104
+ options=options,
105
+ custom=bool(first.get("custom")),
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class PromptResult:
111
+ """The outcome of a blocking prompt: assistant text + any errors."""
112
+
113
+ text: str
114
+ aborted: bool
115
+ error: str | None
116
+
117
+
118
+ class OpenCodeClient:
119
+ """Thin sync wrapper around the OpenCode server HTTP API."""
120
+
121
+ def __init__(
122
+ self,
123
+ base_url: str,
124
+ *,
125
+ username: str | None = None,
126
+ password: str | None = None,
127
+ directory: str | None = None,
128
+ default_model: str | None = None,
129
+ timeout: float = 30.0,
130
+ prompt_timeout: float = 600.0,
131
+ ) -> None:
132
+ self._base = base_url.rstrip("/")
133
+ self._directory = directory
134
+ self._default_model = default_model
135
+ self._prompt_timeout = prompt_timeout
136
+ auth = httpx.BasicAuth(username, password) if username else None
137
+ self._client = httpx.Client(base_url=self._base, auth=auth, timeout=timeout)
138
+
139
+ def __enter__(self) -> OpenCodeClient:
140
+ return self
141
+
142
+ def __exit__(self, *_exc: object) -> None:
143
+ self.close()
144
+
145
+ def close(self) -> None:
146
+ self._client.close()
147
+
148
+ # --- health ------------------------------------------------------------
149
+
150
+ def health(self) -> bool:
151
+ """Return True iff the server reports healthy. Never raises."""
152
+ try:
153
+ resp = self._client.get("/global/health", timeout=5.0)
154
+ resp.raise_for_status()
155
+ return bool(resp.json().get("healthy"))
156
+ except (httpx.HTTPError, ValueError):
157
+ return False
158
+
159
+ # --- sessions ----------------------------------------------------------
160
+
161
+ def create_session(self, title: str | None = None, directory: str | None = None) -> str:
162
+ body: dict[str, Any] = {}
163
+ if title:
164
+ body["title"] = title
165
+ # None -> use the client's configured directory; a path overrides it.
166
+ data = self._post("/session", body, directory=directory or _DEFAULT_DIR)
167
+ session_id = data.get("id")
168
+ if not session_id:
169
+ raise OpenCodeError(f"session create returned no id: {data!r}")
170
+ return session_id
171
+
172
+ def list_sessions(self, directory: str | None = None) -> list[dict[str, Any]]:
173
+ # /session is project-scoped: None -> client default directory; a path
174
+ # filters to that project (matching what the OpenCode TUI/desktop shows).
175
+ return self._get("/session", directory=directory or _DEFAULT_DIR) or []
176
+
177
+ def abort(self, session_id: str) -> bool:
178
+ return bool(self._post(f"/session/{session_id}/abort", {}))
179
+
180
+ def prompt(
181
+ self,
182
+ session_id: str,
183
+ text: str,
184
+ *,
185
+ model: str | None = None,
186
+ agent: str | None = None,
187
+ extra_parts: list[dict[str, Any]] | None = None,
188
+ ) -> PromptResult:
189
+ """Send a prompt and block until the assistant turn completes.
190
+
191
+ ``model`` overrides the default ("providerID/modelID"); ``agent`` selects
192
+ an agent (e.g. "plan"/"build"); ``extra_parts`` appends file parts.
193
+ """
194
+ parts: list[dict[str, Any]] = [{"type": "text", "text": text}]
195
+ if extra_parts:
196
+ parts.extend(extra_parts)
197
+ body: dict[str, Any] = {"parts": parts}
198
+ model_ref = _parse_model(model or self._default_model)
199
+ if model_ref is not None:
200
+ body["model"] = model_ref
201
+ if agent:
202
+ body["agent"] = agent
203
+ data = self._post(
204
+ f"/session/{session_id}/message",
205
+ body,
206
+ timeout=self._prompt_timeout,
207
+ )
208
+ return _prompt_result(data)
209
+
210
+ def rename_session(self, session_id: str, title: str) -> None:
211
+ self._patch(f"/session/{session_id}", {"title": title})
212
+
213
+ def revert(self, session_id: str, message_id: str, part_id: str | None = None) -> None:
214
+ body: dict[str, Any] = {"messageID": message_id}
215
+ if part_id:
216
+ body["partID"] = part_id
217
+ self._post(f"/session/{session_id}/revert", body)
218
+
219
+ def unrevert(self, session_id: str) -> None:
220
+ self._post(f"/session/{session_id}/unrevert", {})
221
+
222
+ def fork(self, session_id: str, message_id: str) -> str:
223
+ data = self._post(f"/session/{session_id}/fork", {"messageID": message_id})
224
+ new_id = data.get("id") if isinstance(data, dict) else None
225
+ if not new_id:
226
+ raise OpenCodeError(f"fork returned no id: {data!r}")
227
+ return new_id
228
+
229
+ def session_messages(self, session_id: str) -> list[dict[str, Any]]:
230
+ return self._get(f"/session/{session_id}/message") or []
231
+
232
+ # --- projects / worktrees ---------------------------------------------
233
+
234
+ def list_projects(self) -> list[dict[str, Any]]:
235
+ return self._get("/project") or []
236
+
237
+ def current_project(self) -> dict[str, Any] | None:
238
+ return self._get("/project/current")
239
+
240
+ def list_worktrees(self) -> list[str]:
241
+ return self._get("/experimental/worktree") or []
242
+
243
+ # --- models / agents / commands / mcp ---------------------------------
244
+
245
+ def list_models(self) -> list[dict[str, Any]]:
246
+ return self._get("/api/model") or []
247
+
248
+ def list_agents(self) -> list[dict[str, Any]]:
249
+ return self._get("/agent") or []
250
+
251
+ def list_commands(self, directory: str | None = None) -> list[dict[str, Any]]:
252
+ return self._get("/command", directory=directory or _DEFAULT_DIR) or []
253
+
254
+ def list_skills(self, directory: str | None = None) -> list[dict[str, Any]]:
255
+ return self._get("/skill", directory=directory or _DEFAULT_DIR) or []
256
+
257
+ def run_command(self, session_id: str, command: str, arguments: str = "") -> PromptResult:
258
+ # `arguments` is a required field on the command endpoint, even when empty.
259
+ body: dict[str, Any] = {"command": command, "arguments": arguments}
260
+ data = self._post(f"/session/{session_id}/command", body, timeout=self._prompt_timeout)
261
+ return _prompt_result(data)
262
+
263
+ def list_mcps(self) -> dict[str, Any]:
264
+ return self._get("/mcp") or {}
265
+
266
+ def toggle_mcp(self, name: str, enable: bool) -> bool:
267
+ action = "connect" if enable else "disconnect"
268
+ return bool(self._post(f"/mcp/{name}/{action}", {}))
269
+
270
+ # --- permissions -------------------------------------------------------
271
+
272
+ def list_permissions(self) -> list[PermissionAsk]:
273
+ return [PermissionAsk.from_request(p) for p in self._get("/permission")]
274
+
275
+ def reply_permission(self, request_id: str, reply: str, message: str | None = None) -> bool:
276
+ if reply not in PERMISSION_REPLIES:
277
+ raise ValueError(f"reply must be one of {PERMISSION_REPLIES}, got {reply!r}")
278
+ body: dict[str, Any] = {"reply": reply}
279
+ if message:
280
+ body["message"] = message
281
+ return bool(self._post(f"/permission/{request_id}/reply", body))
282
+
283
+ # --- questions ---------------------------------------------------------
284
+
285
+ def reply_question(self, request_id: str, answer: str) -> bool:
286
+ """Answer a single-question agent prompt. ``answer`` is the chosen
287
+ option label (or free text when the question allows custom input)."""
288
+ return bool(self._post(f"/question/{request_id}/reply", {"answers": [[answer]]}))
289
+
290
+ def reject_question(self, request_id: str) -> bool:
291
+ return bool(self._post(f"/question/{request_id}/reject", {}))
292
+
293
+ # --- events (SSE) ------------------------------------------------------
294
+
295
+ def iter_events(self) -> Iterator[dict[str, Any]]:
296
+ """Yield decoded SSE event payloads from ``GET /global/event``.
297
+
298
+ Each yielded dict is the GlobalEvent ``payload`` object, i.e. has
299
+ ``{"type": <event-type>, "properties": {...}}``. Raises
300
+ OpenCodeDownError on transport failure so the caller can reconnect.
301
+ """
302
+ try:
303
+ with self._client.stream("GET", "/global/event", timeout=None) as resp:
304
+ resp.raise_for_status()
305
+ for line in resp.iter_lines():
306
+ if not line or not line.startswith("data:"):
307
+ continue
308
+ raw = line[len("data:") :].strip()
309
+ if not raw:
310
+ continue
311
+ try:
312
+ event = json.loads(raw)
313
+ except ValueError:
314
+ continue
315
+ payload = event.get("payload") if isinstance(event, dict) else None
316
+ if isinstance(payload, dict) and "type" in payload:
317
+ yield payload
318
+ except httpx.HTTPError as exc:
319
+ raise OpenCodeDownError(f"event stream failed: {exc}") from exc
320
+
321
+ # --- internal ----------------------------------------------------------
322
+
323
+ def _get(self, path: str, *, directory: str | None | object = _DEFAULT_DIR) -> Any:
324
+ if directory is _DEFAULT_DIR:
325
+ params = self._dir_params()
326
+ else:
327
+ params = {"directory": directory} if directory else None
328
+ try:
329
+ resp = self._client.get(path, params=params)
330
+ except httpx.TransportError as exc:
331
+ raise OpenCodeDownError(f"GET {path} failed: {exc}") from exc
332
+ return self._unwrap(resp, path)
333
+
334
+ def _post(
335
+ self,
336
+ path: str,
337
+ body: dict[str, Any],
338
+ *,
339
+ timeout: float | None = None,
340
+ directory: str | None | object = _DEFAULT_DIR,
341
+ ) -> Any:
342
+ # directory=_DEFAULT_DIR -> use the client's configured directory;
343
+ # an explicit value (or None) overrides it for this call.
344
+ params = (
345
+ self._dir_params()
346
+ if directory is _DEFAULT_DIR
347
+ else ({"directory": directory} if directory else None)
348
+ )
349
+ try:
350
+ resp = self._client.post(
351
+ path,
352
+ json=body,
353
+ params=params,
354
+ timeout=httpx.USE_CLIENT_DEFAULT if timeout is None else timeout,
355
+ )
356
+ except httpx.TransportError as exc:
357
+ raise OpenCodeDownError(f"POST {path} failed: {exc}") from exc
358
+ return self._unwrap(resp, path)
359
+
360
+ def _patch(self, path: str, body: dict[str, Any]) -> Any:
361
+ try:
362
+ resp = self._client.patch(path, json=body, params=self._dir_params())
363
+ except httpx.TransportError as exc:
364
+ raise OpenCodeDownError(f"PATCH {path} failed: {exc}") from exc
365
+ return self._unwrap(resp, path)
366
+
367
+ def _dir_params(self) -> dict[str, str] | None:
368
+ return {"directory": self._directory} if self._directory else None
369
+
370
+ @staticmethod
371
+ def _unwrap(resp: httpx.Response, path: str) -> Any:
372
+ if resp.status_code >= 400:
373
+ raise OpenCodeError(f"{path} -> HTTP {resp.status_code}: {resp.text[:300]}")
374
+ if not resp.content:
375
+ return None
376
+ try:
377
+ return resp.json()
378
+ except ValueError:
379
+ return resp.text
380
+
381
+
382
+ def _parse_model(model: str | None) -> dict[str, str] | None:
383
+ """Turn "providerID/modelID" into the API's model object."""
384
+ if not model:
385
+ return None
386
+ provider, _, model_id = model.partition("/")
387
+ if not provider or not model_id:
388
+ raise ValueError(f'model must be "providerID/modelID", got {model!r}')
389
+ return {"providerID": provider, "modelID": model_id}
390
+
391
+
392
+ def _prompt_result(data: Any) -> PromptResult:
393
+ """Extract assistant text + error from a {info, parts} response."""
394
+ if not isinstance(data, dict):
395
+ return PromptResult(text="", aborted=False, error="unexpected response")
396
+ info = data.get("info") or {}
397
+ parts = data.get("parts") or []
398
+ texts = [p.get("text", "") for p in parts if isinstance(p, dict) and p.get("type") == "text"]
399
+ text = "\n".join(t for t in texts if t).strip()
400
+
401
+ error = info.get("error")
402
+ aborted = False
403
+ err_msg: str | None = None
404
+ if isinstance(error, dict):
405
+ name = error.get("name", "")
406
+ aborted = name == "MessageAbortedError"
407
+ err_msg = None if aborted else (error.get("data", {}).get("message") or name or "error")
408
+ return PromptResult(text=text, aborted=aborted, error=err_msg)
409
+
410
+
411
+ def wait_for_healthy(client: OpenCodeClient, attempts: int = 1, delay: float = 1.0) -> bool:
412
+ """Poll health up to ``attempts`` times. Returns True on first success."""
413
+ for i in range(attempts):
414
+ if client.health():
415
+ return True
416
+ if i < attempts - 1:
417
+ time.sleep(delay)
418
+ return False
@@ -0,0 +1,115 @@
1
+ """Unified pending-interaction registry.
2
+
3
+ Talk has no inline buttons, so every interactive flow (permission, agent
4
+ question, list picker) is driven by the *next reply* in the conversation. At
5
+ most one interaction is pending per conversation; OpenCode serialises these
6
+ within a session. The bridge consults the pending interaction before normal
7
+ message handling (see ``bridge._handle_message``):
8
+
9
+ - **permission** — answered by ``ja``/``immer``/``nein`` (``interpret_reply``).
10
+ - **question** — an agent question; a numbered option or free text answers it.
11
+ - **selection** — a numbered list; a bare integer runs the stored callback.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+
20
+ from .opencode import PermissionAsk, QuestionAsk
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SelectItem:
25
+ label: str
26
+ value: str
27
+ description: str = ""
28
+
29
+
30
+ @dataclass
31
+ class PermissionPending:
32
+ ask: PermissionAsk
33
+ kind: str = field(default="permission", init=False)
34
+
35
+
36
+ @dataclass
37
+ class QuestionPending:
38
+ ask: QuestionAsk
39
+ kind: str = field(default="question", init=False)
40
+
41
+
42
+ @dataclass
43
+ class SelectionPending:
44
+ title: str
45
+ items: list[SelectItem]
46
+ on_select: Callable[[str], None] # receives the chosen item's value
47
+ kind: str = field(default="selection", init=False)
48
+
49
+
50
+ Pending = PermissionPending | QuestionPending | SelectionPending
51
+
52
+
53
+ class PendingRegistry:
54
+ """Thread-safe one-pending-interaction-per-conversation store."""
55
+
56
+ def __init__(self) -> None:
57
+ self._lock = threading.Lock()
58
+ self._by_token: dict[str, Pending] = {}
59
+
60
+ def set(self, token: str, pending: Pending) -> None:
61
+ with self._lock:
62
+ self._by_token[token] = pending
63
+
64
+ def get(self, token: str) -> Pending | None:
65
+ with self._lock:
66
+ return self._by_token.get(token)
67
+
68
+ def pop(self, token: str) -> Pending | None:
69
+ with self._lock:
70
+ return self._by_token.pop(token, None)
71
+
72
+ def has(self, token: str) -> bool:
73
+ with self._lock:
74
+ return token in self._by_token
75
+
76
+
77
+ def format_selection(title: str, items: list[SelectItem], hint: str = "_Antworte mit der Nummer._") -> str:
78
+ """Render a numbered picker. Reply with the number to choose."""
79
+ lines = [title]
80
+ for i, item in enumerate(items, 1):
81
+ line = f"{i}. {item.label}"
82
+ if item.description:
83
+ line += f" — {item.description}"
84
+ lines.append(line)
85
+ lines.append(hint)
86
+ return "\n".join(lines)
87
+
88
+
89
+ def parse_choice(text: str, count: int) -> int | None:
90
+ """Parse a bare 1..count integer reply into a 0-based index, else None."""
91
+ token = text.strip()
92
+ if not token.isdigit():
93
+ return None
94
+ n = int(token)
95
+ return n - 1 if 1 <= n <= count else None
96
+
97
+
98
+ def format_question(ask: QuestionAsk) -> str:
99
+ """Render an agent question with its options as a numbered picker."""
100
+ lines: list[str] = []
101
+ if ask.header:
102
+ lines.append(f"❓ *{ask.header}*")
103
+ if ask.question:
104
+ lines.append(ask.question)
105
+ for i, opt in enumerate(ask.options, 1):
106
+ line = f"{i}. {opt.label}"
107
+ if opt.description:
108
+ line += f" — {opt.description}"
109
+ lines.append(line)
110
+ if ask.options:
111
+ hint = "Antworte mit der Nummer" + (" oder mit freiem Text." if ask.custom else ".")
112
+ else:
113
+ hint = "Antworte frei."
114
+ lines.append(f"_{hint}_")
115
+ return "\n".join(lines)
@@ -0,0 +1,79 @@
1
+ """Map OpenCode permission requests to Talk yes/no prompts and back.
2
+
3
+ When OpenCode wants to run something dangerous (shell, file write) it emits a
4
+ ``permission.asked`` event and blocks. The bridge posts a concise prompt into
5
+ the bound conversation; the next reply from an allowlisted user is interpreted
6
+ as the answer and sent to ``POST /permission/{id}/reply``.
7
+
8
+ Nothing from the tool payload beyond the permission kind and the suggested
9
+ patterns is echoed, and even those are length-capped, so command arguments that
10
+ might contain secrets are never posted verbatim.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+
17
+ from .opencode import PermissionAsk
18
+
19
+ # Reply-word -> OpenCode outcome. Lowercased exact-token match.
20
+ _ALLOW_ONCE = {"ja", "yes", "y", "j", "ok", "allow", "erlauben"}
21
+ _ALLOW_ALWAYS = {"immer", "always", "a"}
22
+ _REJECT = {"nein", "no", "n", "deny", "reject", "ablehnen", "stop"}
23
+
24
+ _MAX_PATTERN_LEN = 80
25
+
26
+
27
+ def interpret_reply(text: str) -> str | None:
28
+ """Return "once"/"always"/"reject", or None if the text isn't an answer."""
29
+ token = text.strip().lower()
30
+ if token in _ALLOW_ALWAYS:
31
+ return "always"
32
+ if token in _ALLOW_ONCE:
33
+ return "once"
34
+ if token in _REJECT:
35
+ return "reject"
36
+ return None
37
+
38
+
39
+ def format_prompt(ask: PermissionAsk) -> str:
40
+ """Build a concise, secret-safe Talk prompt for a permission request."""
41
+ kind = ask.permission or "eine Aktion"
42
+ detail = ""
43
+ if ask.patterns:
44
+ pattern = ask.patterns[0]
45
+ if len(pattern) > _MAX_PATTERN_LEN:
46
+ pattern = pattern[:_MAX_PATTERN_LEN] + "…"
47
+ detail = f" (`{pattern}`)"
48
+ return (
49
+ f"🔐 OpenCode möchte *{kind}*{detail} ausführen.\n"
50
+ "Antworte `ja` (einmal), `immer` (für diese Session) oder `nein`."
51
+ )
52
+
53
+
54
+ class PendingPermissions:
55
+ """Thread-safe registry of the permission awaiting a reply per conversation.
56
+
57
+ Only one pending permission per conversation is tracked; OpenCode serialises
58
+ permission asks within a session, so a newer ask replaces an older one.
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ self._lock = threading.Lock()
63
+ self._by_token: dict[str, PermissionAsk] = {}
64
+
65
+ def set(self, token: str, ask: PermissionAsk) -> None:
66
+ with self._lock:
67
+ self._by_token[token] = ask
68
+
69
+ def get(self, token: str) -> PermissionAsk | None:
70
+ with self._lock:
71
+ return self._by_token.get(token)
72
+
73
+ def pop(self, token: str) -> PermissionAsk | None:
74
+ with self._lock:
75
+ return self._by_token.pop(token, None)
76
+
77
+ def has(self, token: str) -> bool:
78
+ with self._lock:
79
+ return token in self._by_token