py-opencode-wrapper 0.2.2__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.
@@ -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 RunConfig, validate_config_for_run, validate_permission_actions
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,21 +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
  )
25
+ from opencode_wrapper.session import OpenCodeSession, PermissionCallback, QuestionCallback
19
26
 
20
27
  __all__ = [
21
28
  "AsyncOpenCodeClient",
29
+ "OpenCodeSession",
30
+ "PermissionCallback",
31
+ "QuestionCallback",
22
32
  "RunConfig",
23
33
  "RunResult",
24
34
  "TokenUsage",
25
35
  "aggregate_run_result",
36
+ "aggregate_server_result",
26
37
  "build_argv",
27
38
  "build_env",
28
39
  "parse_event_line",
29
40
  "run_result_fuzzy_text",
30
41
  "resolve_binary",
42
+ "split_model",
31
43
  "validate_config_for_run",
32
44
  "validate_permission_actions",
33
45
  "OpenCodeError",
@@ -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
- for f in run_cfg.files:
63
- cmd.extend(["-f", str(f)])
64
- if run_cfg.title:
65
- cmd.extend(["--title", run_cfg.title])
66
- if run_cfg.attach:
67
- cmd.extend(["--attach", run_cfg.attach])
68
- if run_cfg.password:
69
- cmd.extend(["-p", run_cfg.password])
70
- if run_cfg.remote_dir:
71
- cmd.extend(["--dir", run_cfg.remote_dir])
72
- if run_cfg.port is not None:
73
- cmd.extend(["--port", str(run_cfg.port)])
74
- if run_cfg.variant:
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)
@@ -313,15 +304,23 @@ class AsyncOpenCodeClient:
313
304
  cwd: str,
314
305
  env: dict[str, str],
315
306
  run_cfg: RunConfig,
307
+ data_home: str | None = None,
316
308
  ) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
317
309
  stderr_lines: list[str] = []
318
310
  cleanup_tmpdirs: list[str] = []
319
311
  # Give each process its own XDG_DATA_HOME so opencode.db is isolated.
320
312
  # Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
321
313
  # and SQLite write locks during tool execution serialize the runs (37–46s delays).
322
- if self._isolate_db:
323
- xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
324
- cleanup_tmpdirs.append(xdg_tmpdir)
314
+ # When *data_home* is provided the caller owns a persistent dir (e.g. an
315
+ # OpenCodeSession reusing one DB across turns), so it is NOT added to
316
+ # cleanup_tmpdirs — the caller deletes it when done.
317
+ managed = data_home is not None
318
+ if self._isolate_db or managed:
319
+ if managed:
320
+ xdg_tmpdir = data_home # type: ignore[assignment]
321
+ else:
322
+ xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
323
+ cleanup_tmpdirs.append(xdg_tmpdir)
325
324
  # Symlink auth.json so provider API keys (stored by `opencode auth`)
326
325
  # are visible in the isolated data dir. Without this, providers
327
326
  # that rely on auth.json (rather than env-var keys) fail with
@@ -331,7 +330,9 @@ class AsyncOpenCodeClient:
331
330
  if real_auth.is_file():
332
331
  iso_oc_dir = Path(xdg_tmpdir) / "opencode"
333
332
  iso_oc_dir.mkdir(parents=True, exist_ok=True)
334
- (iso_oc_dir / "auth.json").symlink_to(real_auth)
333
+ link = iso_oc_dir / "auth.json"
334
+ if not link.exists(): # reused across turns — guard re-symlink
335
+ link.symlink_to(real_auth)
335
336
  env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
336
337
  # When the caller has not opted into host-config inheritance (the
337
338
  # default), redirect XDG_CONFIG_HOME / OPENCODE_TEST_HOME at a sanitized
@@ -425,6 +426,7 @@ class AsyncOpenCodeClient:
425
426
  log_file: str | Path | None = None,
426
427
  max_retries: int = 2,
427
428
  retry_delay_s: float = 1.0,
429
+ data_home: str | None = None,
428
430
  ) -> RunResult:
429
431
  """
430
432
  Run to completion and return a :class:`RunResult`.
@@ -457,7 +459,7 @@ class AsyncOpenCodeClient:
457
459
 
458
460
  log_fh = open(log_file, "w") if log_file is not None else None
459
461
  try:
460
- async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
462
+ async with self._managed_process(argv, cwd, env, run_cfg, data_home=data_home) as (proc, stderr_lines):
461
463
  async for line, ev in _stdout_line_event_iter(proc):
462
464
  raw_acc.append(line)
463
465
  events_acc.append(ev)
@@ -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 validate_permission_actions(obj: Any, *, _path: str = "") -> None:
190
- """Ensure string leaves are non-interactive OpenCode permission actions.
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 terminal to prompt —
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:
@@ -70,6 +70,16 @@ def _text_from_event(ev: dict[str, Any]) -> str | None:
70
70
  return None
71
71
 
72
72
 
73
+ def _session_id_from_event(ev: dict[str, Any]) -> str | None:
74
+ sid = ev.get("sessionID")
75
+ if isinstance(sid, str):
76
+ return sid
77
+ part = ev.get("part")
78
+ if isinstance(part, dict) and isinstance(part.get("sessionID"), str):
79
+ return part["sessionID"]
80
+ return None
81
+
82
+
73
83
  def run_result_fuzzy_text(result: "RunResult") -> str:
74
84
  """
75
85
  Best-effort extract human-visible model output across varying ``--format json`` shapes.
@@ -136,9 +146,12 @@ class RunResult:
136
146
  token_usage: TokenUsage = field(default_factory=TokenUsage)
137
147
  total_cost: float = 0.0
138
148
  turns: int = 0
149
+ session_id: str | None = None
139
150
 
140
151
  def append_event(self, ev: dict[str, Any]) -> None:
141
152
  self.events.append(ev)
153
+ if self.session_id is None:
154
+ self.session_id = _session_id_from_event(ev)
142
155
  chunk = _text_from_event(ev)
143
156
  if chunk:
144
157
  self.final_text += chunk
@@ -190,3 +203,121 @@ def aggregate_run_result(
190
203
  for ev in events:
191
204
  r.append_event(ev)
192
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)