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.
- opencode_talk_bridge/__init__.py +8 -0
- opencode_talk_bridge/__main__.py +116 -0
- opencode_talk_bridge/allowlist.py +33 -0
- opencode_talk_bridge/bridge.py +1014 -0
- opencode_talk_bridge/commands.py +60 -0
- opencode_talk_bridge/config.py +169 -0
- opencode_talk_bridge/events.py +85 -0
- opencode_talk_bridge/init.py +118 -0
- opencode_talk_bridge/messages.py +226 -0
- opencode_talk_bridge/opencode.py +418 -0
- opencode_talk_bridge/pending.py +115 -0
- opencode_talk_bridge/permissions.py +79 -0
- opencode_talk_bridge/scheduler.py +144 -0
- opencode_talk_bridge/sessions.py +141 -0
- opencode_talk_bridge/status.py +88 -0
- opencode_talk_bridge/streaming.py +78 -0
- opencode_talk_bridge/stt.py +48 -0
- opencode_talk_bridge/talk.py +171 -0
- opencode_talk_bridge/tts.py +43 -0
- opencode_talk_bridge/webdav.py +93 -0
- opencode_talk_bridge-0.2.7.dist-info/METADATA +284 -0
- opencode_talk_bridge-0.2.7.dist-info/RECORD +25 -0
- opencode_talk_bridge-0.2.7.dist-info/WHEEL +4 -0
- opencode_talk_bridge-0.2.7.dist-info/entry_points.txt +2 -0
- opencode_talk_bridge-0.2.7.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|