py-opencode-wrapper 0.3.0__py3-none-any.whl → 0.3.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_wrapper/__init__.py +12 -2
- opencode_wrapper/client.py +23 -32
- opencode_wrapper/config.py +42 -28
- opencode_wrapper/events.py +118 -0
- opencode_wrapper/server.py +255 -0
- opencode_wrapper/session.py +211 -43
- {py_opencode_wrapper-0.3.0.dist-info → py_opencode_wrapper-0.3.1.dist-info}/METADATA +85 -19
- py_opencode_wrapper-0.3.1.dist-info/RECORD +12 -0
- py_opencode_wrapper-0.3.0.dist-info/RECORD +0 -11
- {py_opencode_wrapper-0.3.0.dist-info → py_opencode_wrapper-0.3.1.dist-info}/WHEEL +0 -0
- {py_opencode_wrapper-0.3.0.dist-info → py_opencode_wrapper-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {py_opencode_wrapper-0.3.0.dist-info → py_opencode_wrapper-0.3.1.dist-info}/top_level.txt +0 -0
opencode_wrapper/__init__.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""OpenCode CLI async wrapper for Python orchestration."""
|
|
2
2
|
|
|
3
3
|
from opencode_wrapper.client import AsyncOpenCodeClient, build_argv, build_env, resolve_binary
|
|
4
|
-
from opencode_wrapper.config import
|
|
4
|
+
from opencode_wrapper.config import (
|
|
5
|
+
RunConfig,
|
|
6
|
+
split_model,
|
|
7
|
+
validate_config_for_run,
|
|
8
|
+
validate_permission_actions,
|
|
9
|
+
)
|
|
5
10
|
from opencode_wrapper.errors import (
|
|
6
11
|
OpenCodeBinaryNotFoundError,
|
|
7
12
|
OpenCodeCancelledError,
|
|
@@ -13,23 +18,28 @@ from opencode_wrapper.events import (
|
|
|
13
18
|
RunResult,
|
|
14
19
|
TokenUsage,
|
|
15
20
|
aggregate_run_result,
|
|
21
|
+
aggregate_server_result,
|
|
16
22
|
parse_event_line,
|
|
17
23
|
run_result_fuzzy_text,
|
|
18
24
|
)
|
|
19
|
-
from opencode_wrapper.session import OpenCodeSession
|
|
25
|
+
from opencode_wrapper.session import OpenCodeSession, PermissionCallback, QuestionCallback
|
|
20
26
|
|
|
21
27
|
__all__ = [
|
|
22
28
|
"AsyncOpenCodeClient",
|
|
23
29
|
"OpenCodeSession",
|
|
30
|
+
"PermissionCallback",
|
|
31
|
+
"QuestionCallback",
|
|
24
32
|
"RunConfig",
|
|
25
33
|
"RunResult",
|
|
26
34
|
"TokenUsage",
|
|
27
35
|
"aggregate_run_result",
|
|
36
|
+
"aggregate_server_result",
|
|
28
37
|
"build_argv",
|
|
29
38
|
"build_env",
|
|
30
39
|
"parse_event_line",
|
|
31
40
|
"run_result_fuzzy_text",
|
|
32
41
|
"resolve_binary",
|
|
42
|
+
"split_model",
|
|
33
43
|
"validate_config_for_run",
|
|
34
44
|
"validate_permission_actions",
|
|
35
45
|
"OpenCodeError",
|
opencode_wrapper/client.py
CHANGED
|
@@ -38,43 +38,34 @@ def build_argv(
|
|
|
38
38
|
prompt: str,
|
|
39
39
|
run_cfg: RunConfig,
|
|
40
40
|
) -> list[str]:
|
|
41
|
-
"""Build ``opencode run`` argument list.
|
|
41
|
+
"""Build ``opencode run`` argument list.
|
|
42
|
+
|
|
43
|
+
``model`` / ``agent`` are structured fields shared with server mode and map
|
|
44
|
+
to ``-m`` / ``--agent``. Every other ``opencode run`` flag is passed through
|
|
45
|
+
``run_cfg.cli_kwargs``: each entry expands to ``--flag`` (bool ``True``),
|
|
46
|
+
``--flag=value`` (multi-char key) / ``-f value`` (single-char key), or a
|
|
47
|
+
repetition per element (list/tuple value). ``False`` / ``None`` are skipped.
|
|
48
|
+
The argv list is handed to ``create_subprocess_exec`` (no shell), so values
|
|
49
|
+
are not subject to shell injection.
|
|
50
|
+
"""
|
|
42
51
|
cmd: list[str] = [binary_resolved, "run", "--format", "json"]
|
|
43
|
-
|
|
44
|
-
if run_cfg.print_logs:
|
|
45
|
-
cmd.append("--print-logs")
|
|
46
|
-
if run_cfg.log_level:
|
|
47
|
-
cmd.extend(["--log-level", run_cfg.log_level])
|
|
48
|
-
if run_cfg.command:
|
|
49
|
-
cmd.extend(["--command", run_cfg.command])
|
|
50
|
-
if run_cfg.continue_session:
|
|
51
|
-
cmd.append("--continue")
|
|
52
|
-
if run_cfg.session_id:
|
|
53
|
-
cmd.extend(["--session", run_cfg.session_id])
|
|
54
|
-
if run_cfg.fork:
|
|
55
|
-
cmd.append("--fork")
|
|
56
|
-
if run_cfg.share is True:
|
|
57
|
-
cmd.append("--share")
|
|
58
52
|
if run_cfg.model:
|
|
59
53
|
cmd.extend(["-m", run_cfg.model])
|
|
60
54
|
if run_cfg.agent:
|
|
61
55
|
cmd.extend(["--agent", run_cfg.agent])
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
cmd.extend(["--variant", run_cfg.variant])
|
|
76
|
-
if run_cfg.record_thinking is True or run_cfg.thinking is True:
|
|
77
|
-
cmd.append("--thinking")
|
|
56
|
+
|
|
57
|
+
for key, val in (run_cfg.cli_kwargs or {}).items():
|
|
58
|
+
flag = f"-{key}" if len(key) == 1 else f"--{key}"
|
|
59
|
+
vals = val if isinstance(val, (list, tuple)) else [val]
|
|
60
|
+
for v in vals:
|
|
61
|
+
if v is True:
|
|
62
|
+
cmd.append(flag)
|
|
63
|
+
elif v is False or v is None:
|
|
64
|
+
continue
|
|
65
|
+
elif len(key) == 1:
|
|
66
|
+
cmd.extend([flag, str(v)])
|
|
67
|
+
else:
|
|
68
|
+
cmd.append(f"{flag}={v}")
|
|
78
69
|
|
|
79
70
|
if prompt:
|
|
80
71
|
cmd.append(prompt)
|
opencode_wrapper/config.py
CHANGED
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from pathlib import Path
|
|
8
7
|
from typing import Any, Dict, Mapping
|
|
9
8
|
|
|
10
9
|
# Permission values accepted by OpenCode
|
|
@@ -131,29 +130,17 @@ def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str,
|
|
|
131
130
|
|
|
132
131
|
@dataclass
|
|
133
132
|
class RunConfig:
|
|
134
|
-
"""Per-invocation settings merged into env and CLI.
|
|
133
|
+
"""Per-invocation settings merged into env and CLI.
|
|
135
134
|
|
|
135
|
+
Most fields are honored by both modes (run mode via CLI/env, server/session
|
|
136
|
+
mode via the prompt body/env). ``cli_kwargs`` is the exception: it is a
|
|
137
|
+
raw passthrough of ``opencode run`` CLI flags and is **ignored by server/
|
|
138
|
+
session mode**, which has no CLI surface.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# --- honored by both modes ---
|
|
136
142
|
agent: str | None = None
|
|
137
143
|
model: str | None = None
|
|
138
|
-
files: tuple[str | Path, ...] = ()
|
|
139
|
-
title: str | None = None
|
|
140
|
-
command: str | None = None
|
|
141
|
-
continue_session: bool = False
|
|
142
|
-
session_id: str | None = None
|
|
143
|
-
fork: bool = False
|
|
144
|
-
share: bool | None = None
|
|
145
|
-
attach: str | None = None
|
|
146
|
-
password: str | None = None
|
|
147
|
-
remote_dir: str | None = None
|
|
148
|
-
port: int | None = None
|
|
149
|
-
variant: str | None = None
|
|
150
|
-
# Include OpenCode reasoning/thinking parts in the JSON event stream.
|
|
151
|
-
# This maps to `opencode run --thinking`; it does not set model reasoning effort.
|
|
152
|
-
record_thinking: bool | None = None
|
|
153
|
-
# Backward-compatible alias for record_thinking.
|
|
154
|
-
thinking: bool | None = None
|
|
155
|
-
print_logs: bool | None = None
|
|
156
|
-
log_level: str | None = None
|
|
157
144
|
disable_autoupdate: bool = True
|
|
158
145
|
inherit_user_config: bool = False
|
|
159
146
|
extra_env: Mapping[str, str] | None = None
|
|
@@ -163,6 +150,16 @@ class RunConfig:
|
|
|
163
150
|
tools: dict[str, Any] | None = None
|
|
164
151
|
instructions: list[str] | None = None
|
|
165
152
|
config_overrides: dict[str, Any] | None = None
|
|
153
|
+
# --- run mode only: passed through verbatim to `opencode run` as CLI flags;
|
|
154
|
+
# server/session mode ignores this entirely.
|
|
155
|
+
# e.g. {"title": "x", "continue": True, "session": "ses_1", "fork": True,
|
|
156
|
+
# "thinking": True, "f": ["a.txt"]}
|
|
157
|
+
# build_argv expands each entry: bool True -> "--flag", a value -> "--flag=value"
|
|
158
|
+
# (long) or "-f value" (single-char), a list -> repeated, False/None -> skipped.
|
|
159
|
+
# Note: server mode has no --thinking equivalent; reasoning parts are produced
|
|
160
|
+
# per the model's reasoning config and published to the SSE bus unconditionally,
|
|
161
|
+
# so they already land in result.events / log_file with no opt-in.
|
|
162
|
+
cli_kwargs: dict[str, Any] | None = None
|
|
166
163
|
|
|
167
164
|
def build_opencode_config_dict(self) -> dict[str, Any]:
|
|
168
165
|
"""Build the dict serialized to ``OPENCODE_CONFIG_CONTENT``."""
|
|
@@ -186,15 +183,32 @@ class RunConfig:
|
|
|
186
183
|
return json.dumps(cfg, ensure_ascii=False)
|
|
187
184
|
|
|
188
185
|
|
|
189
|
-
def
|
|
190
|
-
"""
|
|
186
|
+
def split_model(model: str) -> dict[str, str]:
|
|
187
|
+
"""Split a ``"providerID/modelID"`` string into the server prompt-body shape.
|
|
188
|
+
|
|
189
|
+
opencode's server API wants ``{"providerID": ..., "modelID": ...}`` whereas
|
|
190
|
+
``opencode run -m`` takes the ``provider/model`` string. Only the first ``/``
|
|
191
|
+
separates provider from model (model ids may themselves contain ``/``). A
|
|
192
|
+
string with no ``/`` is treated as a bare model id with an empty provider,
|
|
193
|
+
letting the server fall back to its default provider resolution.
|
|
194
|
+
"""
|
|
195
|
+
provider, sep, rest = model.partition("/")
|
|
196
|
+
if not sep:
|
|
197
|
+
return {"providerID": "", "modelID": model}
|
|
198
|
+
return {"providerID": provider, "modelID": rest}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_permission_actions(obj: Any, *, _path: str = "", allow_ask: bool = False) -> None:
|
|
202
|
+
"""Ensure string leaves are valid OpenCode permission actions.
|
|
191
203
|
|
|
192
|
-
``"ask"`` is rejected because the subprocess has no
|
|
193
|
-
it would block forever.
|
|
204
|
+
``"ask"`` is rejected by default because the run-mode subprocess has no
|
|
205
|
+
terminal to prompt — it would block forever. The server/session path passes
|
|
206
|
+
``allow_ask=True`` because there a ``permission.asked`` event is answerable via
|
|
207
|
+
the ``on_permission`` callback.
|
|
194
208
|
"""
|
|
195
|
-
allowed = frozenset({"allow", "deny"})
|
|
209
|
+
allowed = frozenset({"allow", "deny", "ask"} if allow_ask else {"allow", "deny"})
|
|
196
210
|
if isinstance(obj, str):
|
|
197
|
-
if obj == "ask":
|
|
211
|
+
if obj == "ask" and not allow_ask:
|
|
198
212
|
loc = f" at {_path!r}" if _path else ""
|
|
199
213
|
raise ValueError(
|
|
200
214
|
f"Permission action 'ask' is not supported in non-interactive "
|
|
@@ -209,7 +223,7 @@ def validate_permission_actions(obj: Any, *, _path: str = "") -> None:
|
|
|
209
223
|
if isinstance(obj, dict):
|
|
210
224
|
for k, v in obj.items():
|
|
211
225
|
child_path = f"{_path}.{k}" if _path else k
|
|
212
|
-
validate_permission_actions(v, _path=child_path)
|
|
226
|
+
validate_permission_actions(v, _path=child_path, allow_ask=allow_ask)
|
|
213
227
|
|
|
214
228
|
|
|
215
229
|
def validate_config_for_run(cfg: RunConfig) -> None:
|
opencode_wrapper/events.py
CHANGED
|
@@ -203,3 +203,121 @@ def aggregate_run_result(
|
|
|
203
203
|
for ev in events:
|
|
204
204
|
r.append_event(ev)
|
|
205
205
|
return r
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Server-mode (opencode serve / SSE) aggregation
|
|
210
|
+
#
|
|
211
|
+
# Server event shapes differ from `opencode run --format json`:
|
|
212
|
+
# {"type": "message.part.updated",
|
|
213
|
+
# "properties": {"sessionID": "...", "part": {"type": "text"|"tool"|"reasoning",
|
|
214
|
+
# "text": "...", "id": "prt_...", ...}}}
|
|
215
|
+
# {"type": "message.updated", "properties": {"info": {"role": "assistant",
|
|
216
|
+
# "tokens": {...}, "cost": ...}}}
|
|
217
|
+
# Run-mode parsing above is intentionally left untouched.
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _server_assistant_text_from_messages(messages: list[dict[str, Any]]) -> str:
|
|
222
|
+
"""Extract the last assistant message's concatenated text parts.
|
|
223
|
+
|
|
224
|
+
``GET /session/{id}/message`` returns ``[{info:{role,...}, parts:[...]}, ...]``.
|
|
225
|
+
The authoritative final answer is the text parts of the final assistant turn.
|
|
226
|
+
"""
|
|
227
|
+
last = ""
|
|
228
|
+
for m in messages:
|
|
229
|
+
info = m.get("info") if isinstance(m, dict) else None
|
|
230
|
+
if not isinstance(info, dict) or info.get("role") != "assistant":
|
|
231
|
+
continue
|
|
232
|
+
parts = m.get("parts")
|
|
233
|
+
if not isinstance(parts, list):
|
|
234
|
+
continue
|
|
235
|
+
texts = [
|
|
236
|
+
p["text"]
|
|
237
|
+
for p in parts
|
|
238
|
+
if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str)
|
|
239
|
+
]
|
|
240
|
+
joined = "".join(texts).strip()
|
|
241
|
+
if joined:
|
|
242
|
+
last = joined
|
|
243
|
+
return last
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _accumulate_token_usage(usage: TokenUsage, tokens: Any, cost_acc: list[float], info: dict) -> None:
|
|
247
|
+
cost = info.get("cost")
|
|
248
|
+
if isinstance(cost, (int, float)):
|
|
249
|
+
cost_acc[0] += float(cost)
|
|
250
|
+
if isinstance(tokens, dict):
|
|
251
|
+
for attr, key in (("total", "total"), ("input", "input"), ("output", "output"), ("reasoning", "reasoning")):
|
|
252
|
+
val = tokens.get(key)
|
|
253
|
+
if isinstance(val, (int, float)):
|
|
254
|
+
setattr(usage, attr, getattr(usage, attr) + int(val))
|
|
255
|
+
cache = tokens.get("cache")
|
|
256
|
+
if isinstance(cache, dict):
|
|
257
|
+
for attr, key in (("cache_read", "read"), ("cache_write", "write")):
|
|
258
|
+
val = cache.get(key)
|
|
259
|
+
if isinstance(val, (int, float)):
|
|
260
|
+
setattr(usage, attr, getattr(usage, attr) + int(val))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def aggregate_server_result(
|
|
264
|
+
*,
|
|
265
|
+
events: list[dict[str, Any]],
|
|
266
|
+
session_id: str | None,
|
|
267
|
+
final_messages: list[dict[str, Any]] | None = None,
|
|
268
|
+
exit_code: int | None = 0,
|
|
269
|
+
stderr: str = "",
|
|
270
|
+
) -> RunResult:
|
|
271
|
+
"""Build a :class:`RunResult` from a server-mode turn's SSE events.
|
|
272
|
+
|
|
273
|
+
``events`` are the raw SSE event dicts collected during the turn. When
|
|
274
|
+
``final_messages`` (the ``GET /session/{id}/message`` payload) is supplied it
|
|
275
|
+
is treated as the authoritative source for final text and token/cost totals;
|
|
276
|
+
otherwise those are reconstructed from the streamed events.
|
|
277
|
+
"""
|
|
278
|
+
r = RunResult(events=list(events), exit_code=exit_code, stderr=stderr, session_id=session_id)
|
|
279
|
+
|
|
280
|
+
# tool_calls + streamed text snapshots keyed by part id (parts are replaced,
|
|
281
|
+
# not appended, as they stream — keep the latest snapshot per id).
|
|
282
|
+
text_by_part: dict[str, str] = {}
|
|
283
|
+
text_order: list[str] = []
|
|
284
|
+
cost_acc = [0.0]
|
|
285
|
+
seen_assistant_msgs: set[str] = set()
|
|
286
|
+
|
|
287
|
+
for ev in events:
|
|
288
|
+
etype = ev.get("type")
|
|
289
|
+
props = ev.get("properties", {}) if isinstance(ev.get("properties"), dict) else {}
|
|
290
|
+
if etype in ("message.part.updated", "message.part.delta"):
|
|
291
|
+
part = props.get("part")
|
|
292
|
+
if not isinstance(part, dict):
|
|
293
|
+
continue
|
|
294
|
+
ptype = part.get("type")
|
|
295
|
+
pid = part.get("id") or ""
|
|
296
|
+
if ptype == "text" and isinstance(part.get("text"), str):
|
|
297
|
+
if pid not in text_by_part:
|
|
298
|
+
text_order.append(pid)
|
|
299
|
+
text_by_part[pid] = part["text"]
|
|
300
|
+
elif ptype == "tool":
|
|
301
|
+
r.tool_calls.append({
|
|
302
|
+
"type": "tool",
|
|
303
|
+
"tool": part.get("tool"),
|
|
304
|
+
"callID": part.get("callID"),
|
|
305
|
+
"state": part.get("state"),
|
|
306
|
+
"id": pid,
|
|
307
|
+
})
|
|
308
|
+
elif etype == "message.updated":
|
|
309
|
+
info = props.get("info")
|
|
310
|
+
if isinstance(info, dict) and info.get("role") == "assistant":
|
|
311
|
+
mid = info.get("id") or ""
|
|
312
|
+
if mid not in seen_assistant_msgs:
|
|
313
|
+
seen_assistant_msgs.add(mid)
|
|
314
|
+
r.turns += 1
|
|
315
|
+
_accumulate_token_usage(r.token_usage, info.get("tokens"), cost_acc, info)
|
|
316
|
+
|
|
317
|
+
r.total_cost = cost_acc[0]
|
|
318
|
+
|
|
319
|
+
if final_messages is not None:
|
|
320
|
+
r.final_text = _server_assistant_text_from_messages(final_messages)
|
|
321
|
+
if not r.final_text:
|
|
322
|
+
r.final_text = "".join(text_by_part[pid] for pid in text_order).strip()
|
|
323
|
+
return r
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Headless ``opencode serve`` lifecycle + a stdlib HTTP/SSE client.
|
|
2
|
+
|
|
3
|
+
Backs :class:`opencode_wrapper.session.OpenCodeSession`. One
|
|
4
|
+
:class:`_OpenCodeServer` owns a single ``opencode serve`` subprocess for the
|
|
5
|
+
lifetime of an ``async with`` session block: it spawns the server with the same
|
|
6
|
+
hermetic env run mode uses, subscribes to the ``/event`` SSE bus, and exposes
|
|
7
|
+
unary POST/GET/DELETE helpers. Stdlib-only (``urllib`` + ``asyncio`` +
|
|
8
|
+
``threading``) — preserves the wrapper's zero-runtime-deps invariant.
|
|
9
|
+
|
|
10
|
+
The SSE bus is consumed in a daemon thread via ``urllib`` (which decodes chunked
|
|
11
|
+
transfer-encoding transparently) and events are dispatched onto per-session
|
|
12
|
+
``asyncio.Queue``s, so one server can fan events out to many sessions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import socket
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections import deque
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from opencode_wrapper.client import build_env, _isolate_user_config
|
|
30
|
+
from opencode_wrapper.config import RunConfig
|
|
31
|
+
from opencode_wrapper.errors import OpenCodeProcessError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _free_port() -> int:
|
|
35
|
+
"""Pick an ephemeral free TCP port on localhost."""
|
|
36
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
37
|
+
s.bind(("127.0.0.1", 0))
|
|
38
|
+
return s.getsockname()[1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _event_session_id(ev: dict[str, Any]) -> str | None:
|
|
42
|
+
"""Best-effort extract the sessionID a server SSE event belongs to."""
|
|
43
|
+
props = ev.get("properties")
|
|
44
|
+
if not isinstance(props, dict):
|
|
45
|
+
return None
|
|
46
|
+
sid = props.get("sessionID")
|
|
47
|
+
if isinstance(sid, str):
|
|
48
|
+
return sid
|
|
49
|
+
for key in ("part", "info"):
|
|
50
|
+
nested = props.get(key)
|
|
51
|
+
if isinstance(nested, dict) and isinstance(nested.get("sessionID"), str):
|
|
52
|
+
return nested["sessionID"]
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _OpenCodeServer:
|
|
57
|
+
"""Owns one ``opencode serve`` process and its ``/event`` SSE subscription."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
binary_resolved: str,
|
|
62
|
+
run_cfg: RunConfig,
|
|
63
|
+
workspace: str,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._binary = binary_resolved
|
|
66
|
+
self._run_cfg = run_cfg
|
|
67
|
+
self._workspace = workspace
|
|
68
|
+
self._port = _free_port()
|
|
69
|
+
self.base = f"http://127.0.0.1:{self._port}"
|
|
70
|
+
|
|
71
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
72
|
+
self._cleanup_dirs: list[str] = []
|
|
73
|
+
self._stderr_tail: deque[str] = deque(maxlen=200)
|
|
74
|
+
|
|
75
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
76
|
+
self._queues: dict[str, asyncio.Queue[dict[str, Any]]] = {}
|
|
77
|
+
self._sse_thread: threading.Thread | None = None
|
|
78
|
+
self._sse_resp: Any = None
|
|
79
|
+
self._sse_stop = threading.Event()
|
|
80
|
+
self._sse_connected = threading.Event()
|
|
81
|
+
self._tasks: list[asyncio.Task[Any]] = []
|
|
82
|
+
|
|
83
|
+
# -- env -----------------------------------------------------------------
|
|
84
|
+
def _build_server_env(self) -> dict[str, str]:
|
|
85
|
+
"""Compose the child env: hermetic config + private SQLite data dir.
|
|
86
|
+
|
|
87
|
+
Reuses run mode's :func:`build_env` (OPENCODE_CONFIG_CONTENT, autoupdate,
|
|
88
|
+
PWD) and :func:`_isolate_user_config` (sanitized XDG_CONFIG_HOME) so the
|
|
89
|
+
session server is isolated exactly like an ``opencode run`` subprocess.
|
|
90
|
+
A private ``XDG_DATA_HOME`` tmpdir keeps the session's SQLite DB off the
|
|
91
|
+
host's global ``opencode.db`` and is removed on :meth:`aclose`.
|
|
92
|
+
"""
|
|
93
|
+
env = build_env(self._run_cfg, cwd=self._workspace)
|
|
94
|
+
if not self._run_cfg.inherit_user_config:
|
|
95
|
+
cfg_tmp = tempfile.mkdtemp(prefix="oc_srv_cfg_")
|
|
96
|
+
self._cleanup_dirs.append(cfg_tmp)
|
|
97
|
+
env = _isolate_user_config(dict(env), Path(cfg_tmp))
|
|
98
|
+
|
|
99
|
+
data_tmp = tempfile.mkdtemp(prefix="oc_srv_data_")
|
|
100
|
+
self._cleanup_dirs.append(data_tmp)
|
|
101
|
+
real_xdg = env.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
|
|
102
|
+
real_auth = Path(real_xdg) / "opencode" / "auth.json"
|
|
103
|
+
if real_auth.is_file():
|
|
104
|
+
iso_oc = Path(data_tmp) / "opencode"
|
|
105
|
+
iso_oc.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
link = iso_oc / "auth.json"
|
|
107
|
+
if not link.exists():
|
|
108
|
+
link.symlink_to(real_auth)
|
|
109
|
+
env["XDG_DATA_HOME"] = data_tmp
|
|
110
|
+
return env
|
|
111
|
+
|
|
112
|
+
# -- lifecycle -----------------------------------------------------------
|
|
113
|
+
async def start(self, *, health_timeout_s: float = 15.0) -> None:
|
|
114
|
+
self._loop = asyncio.get_running_loop()
|
|
115
|
+
env = self._build_server_env()
|
|
116
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
117
|
+
self._binary, "serve", "--port", str(self._port), "--hostname", "127.0.0.1",
|
|
118
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
119
|
+
stderr=asyncio.subprocess.PIPE,
|
|
120
|
+
cwd=self._workspace,
|
|
121
|
+
env=env,
|
|
122
|
+
)
|
|
123
|
+
self._tasks.append(asyncio.create_task(self._drain_stderr()))
|
|
124
|
+
|
|
125
|
+
deadline = self._loop.time() + health_timeout_s
|
|
126
|
+
while True:
|
|
127
|
+
if self._proc.returncode is not None:
|
|
128
|
+
raise OpenCodeProcessError(
|
|
129
|
+
exit_code=self._proc.returncode,
|
|
130
|
+
stderr="".join(self._stderr_tail),
|
|
131
|
+
events=[],
|
|
132
|
+
raw_stdout_lines=[],
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
await asyncio.to_thread(self._get_sync, "/session")
|
|
136
|
+
break
|
|
137
|
+
except (urllib.error.URLError, ConnectionError, OSError):
|
|
138
|
+
if self._loop.time() >= deadline:
|
|
139
|
+
await self.aclose()
|
|
140
|
+
raise OpenCodeProcessError(
|
|
141
|
+
exit_code=-1,
|
|
142
|
+
stderr="opencode serve did not become healthy in "
|
|
143
|
+
f"{health_timeout_s}s\n" + "".join(self._stderr_tail),
|
|
144
|
+
events=[],
|
|
145
|
+
raw_stdout_lines=[],
|
|
146
|
+
)
|
|
147
|
+
await asyncio.sleep(0.1)
|
|
148
|
+
|
|
149
|
+
# Connect the SSE bus before any prompt is sent so no events are missed.
|
|
150
|
+
self._sse_thread = threading.Thread(target=self._run_sse, daemon=True)
|
|
151
|
+
self._sse_thread.start()
|
|
152
|
+
await asyncio.to_thread(self._sse_connected.wait, 5.0)
|
|
153
|
+
|
|
154
|
+
async def _drain_stderr(self) -> None:
|
|
155
|
+
assert self._proc is not None and self._proc.stderr is not None
|
|
156
|
+
while True:
|
|
157
|
+
line = await self._proc.stderr.readline()
|
|
158
|
+
if not line:
|
|
159
|
+
break
|
|
160
|
+
self._stderr_tail.append(line.decode(errors="replace"))
|
|
161
|
+
|
|
162
|
+
async def aclose(self) -> None:
|
|
163
|
+
self._sse_stop.set()
|
|
164
|
+
if self._sse_resp is not None:
|
|
165
|
+
try:
|
|
166
|
+
self._sse_resp.close()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
for t in self._tasks:
|
|
170
|
+
t.cancel()
|
|
171
|
+
if self._proc is not None and self._proc.returncode is None:
|
|
172
|
+
try:
|
|
173
|
+
self._proc.terminate()
|
|
174
|
+
await asyncio.wait_for(self._proc.wait(), timeout=5)
|
|
175
|
+
except asyncio.TimeoutError:
|
|
176
|
+
self._proc.kill()
|
|
177
|
+
except ProcessLookupError:
|
|
178
|
+
pass
|
|
179
|
+
for d in self._cleanup_dirs:
|
|
180
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def stderr_tail(self) -> str:
|
|
184
|
+
return "".join(self._stderr_tail)
|
|
185
|
+
|
|
186
|
+
# -- SSE -----------------------------------------------------------------
|
|
187
|
+
def _run_sse(self) -> None:
|
|
188
|
+
try:
|
|
189
|
+
resp = urllib.request.urlopen(self.base + "/event", timeout=None)
|
|
190
|
+
self._sse_resp = resp
|
|
191
|
+
assert self._loop is not None
|
|
192
|
+
self._loop.call_soon_threadsafe(self._sse_connected.set)
|
|
193
|
+
for raw in resp:
|
|
194
|
+
if self._sse_stop.is_set():
|
|
195
|
+
break
|
|
196
|
+
line = raw.decode(errors="replace").rstrip("\r\n")
|
|
197
|
+
if not line.startswith("data:"):
|
|
198
|
+
continue
|
|
199
|
+
payload = line[len("data:"):].strip()
|
|
200
|
+
if not payload:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
ev = json.loads(payload)
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
continue
|
|
206
|
+
self._loop.call_soon_threadsafe(self._dispatch, ev)
|
|
207
|
+
except Exception as exc: # noqa: BLE001 - surface to consumers as an event
|
|
208
|
+
if self._loop is not None and not self._sse_stop.is_set():
|
|
209
|
+
self._loop.call_soon_threadsafe(
|
|
210
|
+
self._dispatch, {"type": "_sse_error", "error": repr(exc)}
|
|
211
|
+
)
|
|
212
|
+
finally:
|
|
213
|
+
self._sse_connected.set()
|
|
214
|
+
|
|
215
|
+
def _dispatch(self, ev: dict[str, Any]) -> None:
|
|
216
|
+
if ev.get("type") == "_sse_error":
|
|
217
|
+
for q in self._queues.values():
|
|
218
|
+
q.put_nowait(ev)
|
|
219
|
+
return
|
|
220
|
+
sid = _event_session_id(ev)
|
|
221
|
+
if sid is not None and sid in self._queues:
|
|
222
|
+
self._queues[sid].put_nowait(ev)
|
|
223
|
+
|
|
224
|
+
def subscribe(self, session_id: str) -> "asyncio.Queue[dict[str, Any]]":
|
|
225
|
+
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
226
|
+
self._queues[session_id] = q
|
|
227
|
+
return q
|
|
228
|
+
|
|
229
|
+
def unsubscribe(self, session_id: str) -> None:
|
|
230
|
+
self._queues.pop(session_id, None)
|
|
231
|
+
|
|
232
|
+
# -- HTTP (stdlib, run in a thread) --------------------------------------
|
|
233
|
+
def _request_sync(self, method: str, path: str, body: dict[str, Any] | None) -> Any:
|
|
234
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
235
|
+
req = urllib.request.Request(
|
|
236
|
+
self.base + path,
|
|
237
|
+
data=data,
|
|
238
|
+
headers={"Content-Type": "application/json"} if data is not None else {},
|
|
239
|
+
method=method,
|
|
240
|
+
)
|
|
241
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
242
|
+
raw = resp.read()
|
|
243
|
+
return json.loads(raw) if raw else None
|
|
244
|
+
|
|
245
|
+
def _get_sync(self, path: str) -> Any:
|
|
246
|
+
return self._request_sync("GET", path, None)
|
|
247
|
+
|
|
248
|
+
async def post(self, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
249
|
+
return await asyncio.to_thread(self._request_sync, "POST", path, body)
|
|
250
|
+
|
|
251
|
+
async def get(self, path: str) -> Any:
|
|
252
|
+
return await asyncio.to_thread(self._request_sync, "GET", path, None)
|
|
253
|
+
|
|
254
|
+
async def delete(self, path: str) -> Any:
|
|
255
|
+
return await asyncio.to_thread(self._request_sync, "DELETE", path, None)
|
opencode_wrapper/session.py
CHANGED
|
@@ -1,42 +1,83 @@
|
|
|
1
|
-
"""Stateful multi-turn conversation over
|
|
1
|
+
"""Stateful multi-turn conversation over an ``opencode serve`` session.
|
|
2
|
+
|
|
3
|
+
``OpenCodeSession`` is an async context manager that owns a headless
|
|
4
|
+
``opencode serve`` process for the duration of the ``async with`` block. On
|
|
5
|
+
enter it spawns the server (with the same hermetic isolation run mode uses) and
|
|
6
|
+
creates one opencode session pinned to the workspace directory; every
|
|
7
|
+
:meth:`send` re-prompts that same session, so the model retains context natively
|
|
8
|
+
across turns. On exit the session is deleted and the server torn down.
|
|
9
|
+
|
|
10
|
+
Unlike run mode, server mode can answer interactive prompts: pass an
|
|
11
|
+
``on_permission`` async callback to pause on a ``permission.asked`` event and
|
|
12
|
+
resume with ``"once"`` / ``"always"`` / ``"reject"``, and/or an ``on_question``
|
|
13
|
+
callback to answer the ``question`` tool's ``question.asked`` event — neither is
|
|
14
|
+
possible with the one-shot run-mode subprocess.
|
|
15
|
+
"""
|
|
2
16
|
|
|
3
17
|
from __future__ import annotations
|
|
4
18
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import tempfile
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
8
21
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
|
|
23
|
+
from urllib.parse import quote
|
|
10
24
|
|
|
11
|
-
from opencode_wrapper.config import RunConfig
|
|
12
|
-
from opencode_wrapper.
|
|
25
|
+
from opencode_wrapper.config import RunConfig, split_model, validate_permission_actions
|
|
26
|
+
from opencode_wrapper.errors import OpenCodeProcessError, OpenCodeTimeoutError
|
|
27
|
+
from opencode_wrapper.events import RunResult, aggregate_server_result
|
|
28
|
+
from opencode_wrapper.server import _OpenCodeServer
|
|
13
29
|
|
|
14
30
|
if TYPE_CHECKING:
|
|
15
31
|
from opencode_wrapper.client import AsyncOpenCodeClient
|
|
16
32
|
|
|
17
|
-
_UNSET:
|
|
33
|
+
_UNSET: Any = object()
|
|
18
34
|
|
|
35
|
+
# An async callback invoked on each permission request; returns the decision.
|
|
36
|
+
PermissionCallback = Callable[[dict[str, Any]], Awaitable[str]]
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
# An async callback invoked on each ``question.asked`` request. Returns the
|
|
39
|
+
# answers: a list with one entry per question, each entry a list of selected
|
|
40
|
+
# option labels (multiple labels only when the question allows ``multiple``).
|
|
41
|
+
# Returning ``None`` rejects the question (the model is told it was dismissed).
|
|
42
|
+
QuestionCallback = Callable[[dict[str, Any]], Awaitable[Optional[list[list[str]]]]]
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
``--session <id>``. The dir is a per-session island (no shared global DB, so
|
|
27
|
-
no cross-session lock contention) and is removed on ``__aexit__``.
|
|
44
|
+
|
|
45
|
+
class OpenCodeSession:
|
|
46
|
+
"""Multi-turn conversation backed by one ``opencode serve`` session.
|
|
28
47
|
|
|
29
48
|
Parameters
|
|
30
49
|
----------
|
|
31
50
|
client:
|
|
32
|
-
The :class:`AsyncOpenCodeClient
|
|
51
|
+
The :class:`AsyncOpenCodeClient`; used only to resolve the ``opencode``
|
|
52
|
+
binary path that the server is spawned from.
|
|
33
53
|
workspace:
|
|
34
|
-
Project directory
|
|
54
|
+
Project directory the session is pinned to (``?directory=``); every
|
|
55
|
+
turn's tools resolve against it.
|
|
35
56
|
run_cfg:
|
|
36
|
-
Base config
|
|
37
|
-
|
|
57
|
+
Base config. ``permission`` / ``mcp`` / ``instructions`` /
|
|
58
|
+
``config_overrides`` are baked into the server at enter (server-global).
|
|
59
|
+
``model`` / ``agent`` / ``tools`` are sent per turn and may be overridden
|
|
60
|
+
per :meth:`send`.
|
|
38
61
|
timeout_s:
|
|
39
|
-
Default per-turn timeout; overridable per :meth:`send
|
|
62
|
+
Default per-turn timeout; overridable per :meth:`send`.
|
|
63
|
+
on_permission:
|
|
64
|
+
Async callback ``(permission_props) -> "once" | "always" | "reject"``.
|
|
65
|
+
When ``None`` (the default), any ``permission.asked`` is auto-rejected so
|
|
66
|
+
a turn never blocks waiting for input.
|
|
67
|
+
on_question:
|
|
68
|
+
Async callback ``(question_props) -> answers | None`` for the ``question``
|
|
69
|
+
tool's ``question.asked`` event. ``answers`` is a list with one entry per
|
|
70
|
+
question, each a list of selected option labels; ``None`` rejects the
|
|
71
|
+
question. When ``None`` (the default), any ``question.asked`` is
|
|
72
|
+
auto-rejected so a turn never blocks. The ``question`` tool is enabled by
|
|
73
|
+
default in ``opencode serve`` (it is gated on ``OPENCODE_CLIENT``, whose
|
|
74
|
+
default ``"cli"`` enables it).
|
|
75
|
+
log_file:
|
|
76
|
+
Session-level event log. When given, every event from every turn is
|
|
77
|
+
appended to this file as a JSON line (flushed immediately), so partial
|
|
78
|
+
progress survives crashes. These are the same event dicts that land in
|
|
79
|
+
each turn's ``result.events``. The file is truncated once at
|
|
80
|
+
``__aenter__`` and accumulates across all turns until ``__aexit__``.
|
|
40
81
|
"""
|
|
41
82
|
|
|
42
83
|
def __init__(
|
|
@@ -46,22 +87,64 @@ class OpenCodeSession:
|
|
|
46
87
|
*,
|
|
47
88
|
run_cfg: RunConfig | None = None,
|
|
48
89
|
timeout_s: float | None = None,
|
|
90
|
+
on_permission: Optional[PermissionCallback] = None,
|
|
91
|
+
on_question: Optional[QuestionCallback] = None,
|
|
92
|
+
log_file: str | Path | None = None,
|
|
49
93
|
) -> None:
|
|
50
94
|
self._client = client
|
|
51
|
-
self._workspace = workspace
|
|
95
|
+
self._workspace = str(Path(workspace).expanduser().resolve())
|
|
52
96
|
self._base_cfg = run_cfg or RunConfig()
|
|
53
97
|
self._timeout_s = timeout_s
|
|
54
|
-
self.
|
|
98
|
+
self._on_permission = on_permission
|
|
99
|
+
self._on_question = on_question
|
|
100
|
+
self._log_file = log_file
|
|
101
|
+
self._log_fh = None
|
|
102
|
+
self._server: _OpenCodeServer | None = None
|
|
55
103
|
self.session_id: str | None = None
|
|
56
104
|
|
|
57
105
|
async def __aenter__(self) -> "OpenCodeSession":
|
|
58
|
-
self.
|
|
106
|
+
if self._base_cfg.permission is not None:
|
|
107
|
+
# In server mode "ask" is answerable via on_permission.
|
|
108
|
+
validate_permission_actions(self._base_cfg.permission, allow_ask=True)
|
|
109
|
+
bin_path = self._client.resolved_binary()
|
|
110
|
+
self._server = _OpenCodeServer(bin_path, self._base_cfg, self._workspace)
|
|
111
|
+
await self._server.start()
|
|
112
|
+
session = await self._server.post(f"/session?directory={self._dir_q()}", {})
|
|
113
|
+
self.session_id = session["id"]
|
|
114
|
+
if self._log_file is not None:
|
|
115
|
+
self._log_fh = open(self._log_file, "w")
|
|
59
116
|
return self
|
|
60
117
|
|
|
61
118
|
async def __aexit__(self, *exc: object) -> None:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
119
|
+
try:
|
|
120
|
+
if self._server is not None:
|
|
121
|
+
if self.session_id:
|
|
122
|
+
try:
|
|
123
|
+
await self._server.delete(f"/session/{self.session_id}")
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
await self._server.aclose()
|
|
127
|
+
self._server = None
|
|
128
|
+
self.session_id = None
|
|
129
|
+
finally:
|
|
130
|
+
if self._log_fh is not None:
|
|
131
|
+
self._log_fh.close()
|
|
132
|
+
self._log_fh = None
|
|
133
|
+
|
|
134
|
+
def _dir_q(self) -> str:
|
|
135
|
+
return quote(self._workspace, safe="")
|
|
136
|
+
|
|
137
|
+
def _build_prompt_body(self, prompt: str, cfg: RunConfig) -> dict[str, Any]:
|
|
138
|
+
# cfg.cli_kwargs is run-mode only (it expands to `opencode run` CLI
|
|
139
|
+
# flags) and is intentionally ignored here — server mode has no CLI.
|
|
140
|
+
body: dict[str, Any] = {"parts": [{"type": "text", "text": prompt}]}
|
|
141
|
+
if cfg.model:
|
|
142
|
+
body["model"] = split_model(cfg.model)
|
|
143
|
+
if cfg.agent:
|
|
144
|
+
body["agent"] = cfg.agent
|
|
145
|
+
if cfg.tools:
|
|
146
|
+
body["tools"] = {k: bool(v) for k, v in cfg.tools.items()}
|
|
147
|
+
return body
|
|
65
148
|
|
|
66
149
|
async def send(
|
|
67
150
|
self,
|
|
@@ -69,22 +152,107 @@ class OpenCodeSession:
|
|
|
69
152
|
*,
|
|
70
153
|
run_cfg: RunConfig | None = None,
|
|
71
154
|
timeout_s: float | object = _UNSET,
|
|
155
|
+
on_permission: Optional[PermissionCallback] | object = _UNSET,
|
|
156
|
+
on_question: Optional[QuestionCallback] | object = _UNSET,
|
|
72
157
|
) -> RunResult:
|
|
73
|
-
"""Run one turn and return its :class:`RunResult
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
158
|
+
"""Run one turn on the persistent session and return its :class:`RunResult`.
|
|
159
|
+
|
|
160
|
+
Per-call ``run_cfg`` only affects prompt-body knobs (``model`` / ``agent``
|
|
161
|
+
/ ``tools``); ``permission`` / ``mcp`` / ``instructions`` are fixed at
|
|
162
|
+
``__aenter__``. ``on_permission`` and ``on_question`` may be overridden
|
|
163
|
+
per call.
|
|
164
|
+
"""
|
|
165
|
+
if self._server is None or self.session_id is None:
|
|
166
|
+
raise RuntimeError("OpenCodeSession.send() must be called inside 'async with'")
|
|
78
167
|
cfg = run_cfg or self._base_cfg
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
168
|
+
on_perm = self._on_permission if on_permission is _UNSET else on_permission # type: ignore[assignment]
|
|
169
|
+
on_q = self._on_question if on_question is _UNSET else on_question # type: ignore[assignment]
|
|
170
|
+
eff_timeout = self._timeout_s if timeout_s is _UNSET else timeout_s # type: ignore[assignment]
|
|
171
|
+
|
|
172
|
+
coro = self._run_turn(prompt, cfg, on_perm, on_q) # type: ignore[arg-type]
|
|
173
|
+
if eff_timeout is not None:
|
|
174
|
+
try:
|
|
175
|
+
return await asyncio.wait_for(coro, timeout=eff_timeout) # type: ignore[arg-type]
|
|
176
|
+
except asyncio.TimeoutError as e:
|
|
177
|
+
raise OpenCodeTimeoutError(
|
|
178
|
+
f"OpenCode session turn exceeded timeout_s={eff_timeout!r}"
|
|
179
|
+
) from e
|
|
180
|
+
return await coro
|
|
181
|
+
|
|
182
|
+
async def _run_turn(
|
|
183
|
+
self,
|
|
184
|
+
prompt: str,
|
|
185
|
+
cfg: RunConfig,
|
|
186
|
+
on_perm: Optional[PermissionCallback],
|
|
187
|
+
on_question: Optional[QuestionCallback] = None,
|
|
188
|
+
) -> RunResult:
|
|
189
|
+
assert self._server is not None and self.session_id is not None
|
|
190
|
+
sid = self.session_id
|
|
191
|
+
server = self._server
|
|
192
|
+
queue = server.subscribe(sid)
|
|
193
|
+
events: list[dict[str, Any]] = []
|
|
194
|
+
try:
|
|
195
|
+
body = self._build_prompt_body(prompt, cfg)
|
|
196
|
+
await server.post(f"/session/{sid}/prompt_async?directory={self._dir_q()}", body)
|
|
197
|
+
|
|
198
|
+
while True:
|
|
199
|
+
ev = await queue.get()
|
|
200
|
+
events.append(ev)
|
|
201
|
+
if self._log_fh is not None:
|
|
202
|
+
self._log_fh.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
|
203
|
+
self._log_fh.flush()
|
|
204
|
+
etype = ev.get("type")
|
|
205
|
+
props = ev.get("properties", {}) if isinstance(ev.get("properties"), dict) else {}
|
|
206
|
+
|
|
207
|
+
if etype == "_sse_error":
|
|
208
|
+
raise OpenCodeProcessError(
|
|
209
|
+
exit_code=-1,
|
|
210
|
+
stderr=str(ev.get("error", "")) + "\n" + server.stderr_tail,
|
|
211
|
+
events=events,
|
|
212
|
+
raw_stdout_lines=[],
|
|
213
|
+
)
|
|
214
|
+
if etype in ("permission.asked", "permission.updated", "permission.ask"):
|
|
215
|
+
pid = props.get("id")
|
|
216
|
+
decision = await on_perm(props) if on_perm is not None else "reject"
|
|
217
|
+
if pid:
|
|
218
|
+
await server.post(
|
|
219
|
+
f"/session/{sid}/permissions/{pid}", {"response": decision}
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
if etype == "question.asked":
|
|
223
|
+
qid = props.get("id")
|
|
224
|
+
answers = await on_question(props) if on_question is not None else None
|
|
225
|
+
if qid:
|
|
226
|
+
if answers is None:
|
|
227
|
+
await server.post(f"/question/{qid}/reject?directory={self._dir_q()}")
|
|
228
|
+
else:
|
|
229
|
+
await server.post(
|
|
230
|
+
f"/question/{qid}/reply?directory={self._dir_q()}",
|
|
231
|
+
{"answers": answers},
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
if etype == "session.error":
|
|
235
|
+
raise OpenCodeProcessError(
|
|
236
|
+
exit_code=-1,
|
|
237
|
+
stderr=f"session.error: {props!r}\n{server.stderr_tail}",
|
|
238
|
+
events=events,
|
|
239
|
+
raw_stdout_lines=[],
|
|
240
|
+
)
|
|
241
|
+
if etype == "session.idle":
|
|
242
|
+
break
|
|
243
|
+
if etype == "session.status":
|
|
244
|
+
status = props.get("status")
|
|
245
|
+
if isinstance(status, dict) and status.get("type") == "idle":
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
final_messages = await server.get(f"/session/{sid}/message?directory={self._dir_q()}")
|
|
250
|
+
except Exception:
|
|
251
|
+
final_messages = None
|
|
252
|
+
if not isinstance(final_messages, list):
|
|
253
|
+
final_messages = None
|
|
254
|
+
return aggregate_server_result(
|
|
255
|
+
events=events, session_id=sid, final_messages=final_messages
|
|
256
|
+
)
|
|
257
|
+
finally:
|
|
258
|
+
server.unsubscribe(sid)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-opencode-wrapper
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
|
|
5
5
|
Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
|
|
6
6
|
Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
|
|
@@ -77,17 +77,21 @@ async def main():
|
|
|
77
77
|
asyncio.run(main())
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
Set `RunConfig(
|
|
81
|
-
parts included in `result.events` and `log_file` JSON lines
|
|
82
|
-
OpenCode's display/output flag `--thinking`; it does
|
|
83
|
-
|
|
84
|
-
reasoning
|
|
80
|
+
Set `RunConfig(cli_kwargs={"thinking": True})` when you want OpenCode
|
|
81
|
+
reasoning/thinking parts included in `result.events` and `log_file` JSON lines in
|
|
82
|
+
**run mode**. This maps to OpenCode's display/output flag `--thinking`; it does
|
|
83
|
+
not change model reasoning effort. In **server/session mode** there is no
|
|
84
|
+
`--thinking` equivalent — reasoning parts are produced per the model's reasoning
|
|
85
|
+
config and streamed onto the SSE bus unconditionally, so they already land in
|
|
86
|
+
`result.events` / `log_file` with no opt-in.
|
|
85
87
|
|
|
86
88
|
### Multi-turn conversation (`OpenCodeSession`)
|
|
87
89
|
|
|
88
|
-
For a stateful, multi-turn chat
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
For a stateful, multi-turn chat, use `OpenCodeSession` as an async context
|
|
91
|
+
manager. Unlike the one-shot `async_run`/`async_stream` (which spawn
|
|
92
|
+
`opencode run` per call), a session owns a headless `opencode serve` process for
|
|
93
|
+
the duration of the `async with` block and re-prompts one server-side session, so
|
|
94
|
+
the model retains context **natively** across turns:
|
|
91
95
|
|
|
92
96
|
```python
|
|
93
97
|
import asyncio
|
|
@@ -97,19 +101,65 @@ async def chat():
|
|
|
97
101
|
client = AsyncOpenCodeClient()
|
|
98
102
|
async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
|
|
99
103
|
r1 = await s.send("My name is Bob.")
|
|
100
|
-
r2 = await s.send("What is my name?") #
|
|
104
|
+
r2 = await s.send("What is my name?") # continues natively → "Bob"
|
|
101
105
|
print(s.session_id, r2.final_text)
|
|
102
106
|
|
|
103
107
|
asyncio.run(chat())
|
|
104
108
|
```
|
|
105
109
|
|
|
106
|
-
On enter, the session
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
On enter, the session spawns `opencode serve` (with the same hermetic isolation
|
|
111
|
+
run mode uses) and creates one session pinned to the workspace; on exit the
|
|
112
|
+
session is deleted and the server torn down. `send()` accepts per-turn `run_cfg`
|
|
113
|
+
and `timeout_s` overrides, but only **prompt-body knobs** (`model` / `agent` /
|
|
114
|
+
`tools`) vary per turn — `permission` / `mcp` / `instructions` are fixed at enter
|
|
115
|
+
(they are server-global).
|
|
116
|
+
|
|
117
|
+
#### Human-in-the-loop permissions
|
|
118
|
+
|
|
119
|
+
Because the server can pause on a permission request, sessions support an
|
|
120
|
+
`on_permission` async callback that run mode cannot. Set `permission={"bash":
|
|
121
|
+
"ask"}` and answer each prompt with `"once"` / `"always"` / `"reject"`:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
async def approve(props): # props: {"id", "sessionID", "permission", ...}
|
|
125
|
+
return "once"
|
|
126
|
+
|
|
127
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(permission={"bash": "ask"}),
|
|
128
|
+
on_permission=approve) as s:
|
|
129
|
+
r = await s.send("Run `echo hi` and tell me the output.")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
When `on_permission` is `None` (the default), any `permission.asked` is
|
|
133
|
+
auto-rejected so a turn never blocks. File attachments are run-mode only — pass
|
|
134
|
+
`RunConfig(cli_kwargs={"f": ["a.txt", "b.png"]})` to `async_run`. Server-mode
|
|
135
|
+
sessions ignore `cli_kwargs`, so embed file content in the prompt instead.
|
|
136
|
+
|
|
137
|
+
#### Answering the model's questions
|
|
138
|
+
|
|
139
|
+
opencode's built-in `question` tool lets the model ask the user multiple-choice
|
|
140
|
+
questions mid-run (gather preferences, clarify, offer choices). Pass an
|
|
141
|
+
`on_question` async callback to answer it. The callback receives the question
|
|
142
|
+
props (`{"id", "sessionID", "questions": [{"question", "header", "options":
|
|
143
|
+
[{"label", "description"}], "multiple"?, "custom"?}], ...}`) and returns a list
|
|
144
|
+
with one entry per question — each a list of selected option labels. Returning
|
|
145
|
+
`None` rejects (dismisses) the question.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
async def answer(props):
|
|
149
|
+
out = []
|
|
150
|
+
for q in props["questions"]:
|
|
151
|
+
out.append([q["options"][0]["label"]]) # pick the first option
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle"),
|
|
155
|
+
on_question=answer) as s:
|
|
156
|
+
r = await s.send("Ask me which database to use, then scaffold it.")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When `on_question` is `None` (the default), any `question.asked` is auto-rejected
|
|
160
|
+
so a turn never blocks. The `question` tool is enabled by default under
|
|
161
|
+
`opencode serve`; set `RunConfig(extra_env={"OPENCODE_ENABLE_QUESTION_TOOL": "1"})`
|
|
162
|
+
to force-enable it regardless of the server's client identity.
|
|
113
163
|
|
|
114
164
|
### Stream structured JSON events
|
|
115
165
|
|
|
@@ -175,7 +225,23 @@ host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
|
175
225
|
|
|
176
226
|
## CLI arguments
|
|
177
227
|
|
|
178
|
-
`
|
|
228
|
+
In run mode, `model` and `agent` map to `-m` and `--agent`. Every other
|
|
229
|
+
`opencode run` flag is passed through `RunConfig.cli_kwargs`, a raw dict expanded
|
|
230
|
+
by `build_argv`:
|
|
231
|
+
|
|
232
|
+
- bool `True` → `--flag` (e.g. `{"fork": True}` → `--fork`)
|
|
233
|
+
- a value → `--flag=value` (e.g. `{"title": "demo"}` → `--title=demo`)
|
|
234
|
+
- a single-char key → `-k value` (e.g. `{"f": "a.txt"}` → `-f a.txt`)
|
|
235
|
+
- a list/tuple → repeated (e.g. `{"f": ["a.txt", "b.txt"]}` → `-f a.txt -f b.txt`)
|
|
236
|
+
- `False` / `None` → skipped
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
RunConfig(model="anthropic/claude", cli_kwargs={"fork": True, "title": "demo", "f": ["a.txt"]})
|
|
240
|
+
# -> opencode run --format json -m anthropic/claude --fork --title=demo -f a.txt <prompt>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Prompt text is appended as the final `opencode run` message argument.
|
|
244
|
+
`cli_kwargs` is ignored by `OpenCodeSession` (server mode has no CLI surface).
|
|
179
245
|
|
|
180
246
|
## Tests
|
|
181
247
|
|
|
@@ -220,7 +286,7 @@ The defaults already handle the common pitfalls when running many `async_run` ca
|
|
|
220
286
|
|
|
221
287
|
To opt out: pass `startup_delay_s=0` (and a large `startup_concurrency`) to drop the startup pacing, `isolate_db=False` to share session history across runs, and `max_retries=0` to disable retries.
|
|
222
288
|
|
|
223
|
-
> For **multi-turn conversations**
|
|
289
|
+
> These notes apply to `async_run` / `async_stream` (run mode). For **multi-turn conversations** use [`OpenCodeSession`](#multi-turn-conversation-opencodesession) instead — it runs `opencode serve` and re-prompts one server-side session, so context is preserved natively with no shared-DB contention.
|
|
224
290
|
|
|
225
291
|
## Notes
|
|
226
292
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
opencode_wrapper/__init__.py,sha256=JPLA98Oj2WZxGl_KyWozRFs0Q-uG2C6x4ccqy2NnBWo,1290
|
|
2
|
+
opencode_wrapper/client.py,sha256=8WbFcodbZ_TA3cglmz9JmUwtvrUIECzzPhysQ49VJBs,20082
|
|
3
|
+
opencode_wrapper/config.py,sha256=js3QBe7ZRhCSXC4PVvXpVOrS9UNqn6eYwg7psijZIlg,9320
|
|
4
|
+
opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
|
|
5
|
+
opencode_wrapper/events.py,sha256=09JwXeK5Qr4lSSDW7CSluc8SsrGIoPIJNCrH-MXyk2U,11892
|
|
6
|
+
opencode_wrapper/server.py,sha256=4suv2eOZ95SVD9aa8tKcGjghAFK5isp5o82lnmb81mI,10247
|
|
7
|
+
opencode_wrapper/session.py,sha256=0GDobxQZZgnUCpZUJmjNnVMUJ-nw9c11Z8eJZhA5hPE,11658
|
|
8
|
+
py_opencode_wrapper-0.3.1.dist-info/licenses/LICENSE,sha256=W9SvvGfo_x1O5jNp-vV1NzRrMGB5C6sjHnQDnGm73qg,1067
|
|
9
|
+
py_opencode_wrapper-0.3.1.dist-info/METADATA,sha256=7BQF7uwAz1ZMWmpXFwhWshkWJS2bEVp_roZs_NdcECA,12112
|
|
10
|
+
py_opencode_wrapper-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
py_opencode_wrapper-0.3.1.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
|
|
12
|
+
py_opencode_wrapper-0.3.1.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
opencode_wrapper/__init__.py,sha256=zX72bGTjtcDDm-lL3JZHj-IiVAHjsFO7uldQ6Up7w3U,1089
|
|
2
|
-
opencode_wrapper/client.py,sha256=T-L9Ubz1FtIfueix9fbg_G7cTs4tWhvLd6xKP58JHrk,20180
|
|
3
|
-
opencode_wrapper/config.py,sha256=TGQ85vd06qQPbP02G6YG9re-rESpYHfhAJ8PaAUqHRg,8076
|
|
4
|
-
opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
|
|
5
|
-
opencode_wrapper/events.py,sha256=x6KcHXB8vLHB5mesMkGxx5QxiaPQOfI-ponwAufcnlg,6896
|
|
6
|
-
opencode_wrapper/session.py,sha256=_weqUbbs2ul5568b086JEBqK7Ni2ehwc6p6-nQFuz1g,3096
|
|
7
|
-
py_opencode_wrapper-0.3.0.dist-info/licenses/LICENSE,sha256=W9SvvGfo_x1O5jNp-vV1NzRrMGB5C6sjHnQDnGm73qg,1067
|
|
8
|
-
py_opencode_wrapper-0.3.0.dist-info/METADATA,sha256=AQoxbJpt1ghHtsxmjXhsQwD3VX7v9hext_uy5dLflDQ,9001
|
|
9
|
-
py_opencode_wrapper-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
-
py_opencode_wrapper-0.3.0.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
|
|
11
|
-
py_opencode_wrapper-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
{py_opencode_wrapper-0.3.0.dist-info → py_opencode_wrapper-0.3.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|