py-opencode-wrapper 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """OpenCode CLI async wrapper for Python orchestration."""
2
+
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
5
+ from opencode_wrapper.errors import (
6
+ OpenCodeBinaryNotFoundError,
7
+ OpenCodeCancelledError,
8
+ OpenCodeError,
9
+ OpenCodeProcessError,
10
+ OpenCodeTimeoutError,
11
+ )
12
+ from opencode_wrapper.events import (
13
+ RunResult,
14
+ TokenUsage,
15
+ aggregate_run_result,
16
+ parse_event_line,
17
+ run_result_fuzzy_text,
18
+ )
19
+
20
+ __all__ = [
21
+ "AsyncOpenCodeClient",
22
+ "RunConfig",
23
+ "RunResult",
24
+ "TokenUsage",
25
+ "aggregate_run_result",
26
+ "build_argv",
27
+ "build_env",
28
+ "parse_event_line",
29
+ "run_result_fuzzy_text",
30
+ "resolve_binary",
31
+ "validate_config_for_run",
32
+ "validate_permission_actions",
33
+ "OpenCodeError",
34
+ "OpenCodeBinaryNotFoundError",
35
+ "OpenCodeProcessError",
36
+ "OpenCodeTimeoutError",
37
+ "OpenCodeCancelledError",
38
+ ]
@@ -0,0 +1,382 @@
1
+ """Async client: spawn ``opencode run --format json`` and stream parsed events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+ from typing import Any, AsyncIterator, Mapping
13
+
14
+ from opencode_wrapper.config import RunConfig, validate_config_for_run
15
+ from opencode_wrapper.errors import (
16
+ OpenCodeBinaryNotFoundError,
17
+ OpenCodeProcessError,
18
+ OpenCodeTimeoutError,
19
+ )
20
+ from opencode_wrapper.events import RunResult, aggregate_run_result, parse_event_line
21
+
22
+
23
+ def resolve_binary(binary: str) -> str:
24
+ """Resolve ``binary`` to an executable path."""
25
+ expanded = Path(binary).expanduser()
26
+ if expanded.is_file():
27
+ return str(expanded)
28
+ found = shutil.which(binary)
29
+ if found:
30
+ return found
31
+ raise OpenCodeBinaryNotFoundError(f"OpenCode binary not found: {binary!r}")
32
+
33
+
34
+ def build_argv(
35
+ binary_resolved: str,
36
+ prompt: str,
37
+ run_cfg: RunConfig,
38
+ ) -> list[str]:
39
+ """Build ``opencode run`` argument list."""
40
+ cmd: list[str] = [binary_resolved, "run", "--format", "json"]
41
+
42
+ if run_cfg.print_logs:
43
+ cmd.append("--print-logs")
44
+ if run_cfg.log_level:
45
+ cmd.extend(["--log-level", run_cfg.log_level])
46
+ if run_cfg.command:
47
+ cmd.extend(["--command", run_cfg.command])
48
+ if run_cfg.continue_session:
49
+ cmd.append("--continue")
50
+ if run_cfg.session_id:
51
+ cmd.extend(["--session", run_cfg.session_id])
52
+ if run_cfg.fork:
53
+ cmd.append("--fork")
54
+ if run_cfg.share is True:
55
+ cmd.append("--share")
56
+ if run_cfg.model:
57
+ cmd.extend(["-m", run_cfg.model])
58
+ if run_cfg.agent:
59
+ cmd.extend(["--agent", run_cfg.agent])
60
+ for f in run_cfg.files:
61
+ cmd.extend(["-f", str(f)])
62
+ if run_cfg.title:
63
+ cmd.extend(["--title", run_cfg.title])
64
+ if run_cfg.attach:
65
+ cmd.extend(["--attach", run_cfg.attach])
66
+ if run_cfg.password:
67
+ cmd.extend(["-p", run_cfg.password])
68
+ if run_cfg.remote_dir:
69
+ cmd.extend(["--dir", run_cfg.remote_dir])
70
+ if run_cfg.port is not None:
71
+ cmd.extend(["--port", str(run_cfg.port)])
72
+ if run_cfg.variant:
73
+ cmd.extend(["--variant", run_cfg.variant])
74
+ if run_cfg.thinking is True:
75
+ cmd.append("--thinking")
76
+
77
+ if prompt:
78
+ cmd.append(prompt)
79
+ return cmd
80
+
81
+
82
+ def build_env(run_cfg: RunConfig, base: Mapping[str, str] | None = None) -> dict[str, str]:
83
+ env = dict(base if base is not None else os.environ)
84
+ if run_cfg.extra_env:
85
+ env.update(dict(run_cfg.extra_env))
86
+ content = run_cfg.opencode_config_content_json()
87
+ if content is not None:
88
+ env["OPENCODE_CONFIG_CONTENT"] = content
89
+ if run_cfg.disable_autoupdate:
90
+ env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"
91
+ return env
92
+
93
+
94
+ async def _readline_unlimited(reader: asyncio.StreamReader) -> bytes:
95
+ """readline with no size limit, works around asyncio's default 64 KiB cap.
96
+
97
+ Uses ``readuntil()`` directly instead of ``readline()``: unlike ``readline()``,
98
+ ``readuntil()`` raises ``LimitOverrunError`` *without* clearing the buffer, so
99
+ we can drain the oversized chunk with ``readexactly()`` and keep looping.
100
+ """
101
+ chunks: list[bytes] = []
102
+ while True:
103
+ try:
104
+ chunk = await reader.readuntil(b"\n")
105
+ if chunks:
106
+ chunks.append(chunk)
107
+ return b"".join(chunks)
108
+ return chunk
109
+ except asyncio.IncompleteReadError as exc:
110
+ # EOF reached before newline — return whatever partial data we have
111
+ if chunks:
112
+ chunks.append(exc.partial)
113
+ return b"".join(chunks)
114
+ return exc.partial
115
+ except asyncio.LimitOverrunError as exc:
116
+ # Buffer limit hit but data is still intact; drain consumed bytes and loop
117
+ chunks.append(bytes(await reader.readexactly(exc.consumed)))
118
+
119
+
120
+ async def _drain_stderr(proc: asyncio.subprocess.Process, out: list[str]) -> None:
121
+ if proc.stderr is None:
122
+ return
123
+ while True:
124
+ chunk = await _readline_unlimited(proc.stderr)
125
+ if not chunk:
126
+ break
127
+ out.append(chunk.decode(errors="replace"))
128
+
129
+
130
+ async def _stdout_line_event_iter(
131
+ proc: asyncio.subprocess.Process,
132
+ ) -> AsyncIterator[tuple[str, dict[str, Any]]]:
133
+ if proc.stdout is None:
134
+ return
135
+ while True:
136
+ line_b = await _readline_unlimited(proc.stdout)
137
+ if not line_b:
138
+ break
139
+ line = line_b.decode(errors="replace")
140
+ yield line, parse_event_line(line)
141
+
142
+
143
+ # Substrings that indicate opencode crashed during SQLite WAL initialisation.
144
+ # This happens when multiple instances race to set journal_mode=WAL before
145
+ # busy_timeout is configured (opencode bug: busy_timeout set after WAL pragma).
146
+ _SQLITE_STARTUP_PATTERNS: tuple[str, ...] = (
147
+ "database is locked",
148
+ "sqlite_busy",
149
+ "sqliteerror",
150
+ "journal_mode",
151
+ "disk i/o error",
152
+ )
153
+
154
+
155
+ def _is_sqlite_startup_error(stderr: str) -> bool:
156
+ """Return True when *stderr* looks like an opencode SQLite initialisation crash."""
157
+ lower = stderr.lower()
158
+ return any(pat in lower for pat in _SQLITE_STARTUP_PATTERNS)
159
+
160
+
161
+ class AsyncOpenCodeClient:
162
+ """
163
+ One-shot async wrapper around the OpenCode CLI.
164
+
165
+ Uses ``opencode run --format json`` with optional ``OPENCODE_CONFIG_CONTENT``.
166
+
167
+ Parameters
168
+ ----------
169
+ startup_concurrency:
170
+ Maximum number of opencode processes that may enter their SQLite
171
+ initialisation window simultaneously. Defaults to ``1`` (serialised
172
+ startup) to avoid the WAL-pragma race that crashes concurrent instances.
173
+ startup_delay_s:
174
+ Seconds to hold the startup semaphore *after* the process is spawned,
175
+ giving SQLite time to finish ``PRAGMA journal_mode = WAL`` before the
176
+ next instance starts. Defaults to ``0.3``.
177
+ isolate_db:
178
+ If ``True`` (default), each run gets a private ``XDG_DATA_HOME`` temp
179
+ directory so opencode stores its SQLite database in isolation. Without
180
+ this, concurrent processes share ``~/.local/share/opencode/opencode.db``
181
+ and SQLite write locks during tool execution serialize otherwise-parallel
182
+ runs (observed 37–46 s delays). Set to ``False`` only if you need runs
183
+ to share session history.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ binary: str = "opencode",
189
+ startup_concurrency: int = 1,
190
+ startup_delay_s: float = 0.3,
191
+ isolate_db: bool = True,
192
+ ) -> None:
193
+ self.binary = binary
194
+ self._resolved_binary: str | None = None
195
+ self._startup_sem = asyncio.Semaphore(startup_concurrency)
196
+ self._startup_delay_s = startup_delay_s
197
+ self._isolate_db = isolate_db
198
+
199
+ def resolved_binary(self) -> str:
200
+ if self._resolved_binary is None:
201
+ self._resolved_binary = resolve_binary(self.binary)
202
+ return self._resolved_binary
203
+
204
+ @asynccontextmanager
205
+ async def _managed_process(
206
+ self,
207
+ argv: list[str],
208
+ cwd: str,
209
+ env: dict[str, str],
210
+ ) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
211
+ stderr_lines: list[str] = []
212
+ # Give each process its own XDG_DATA_HOME so opencode.db is isolated.
213
+ # Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
214
+ # and SQLite write locks during tool execution serialize the runs (37–46s delays).
215
+ if self._isolate_db:
216
+ xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
217
+ env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
218
+ else:
219
+ xdg_tmpdir = None
220
+ # Serialise process startup to avoid the SQLite WAL-pragma race.
221
+ # The semaphore is released as soon as the startup window has elapsed,
222
+ # so all processes run concurrently after their individual delay.
223
+ async with self._startup_sem:
224
+ proc = await asyncio.create_subprocess_exec(
225
+ *argv,
226
+ cwd=cwd,
227
+ stdout=asyncio.subprocess.PIPE,
228
+ stderr=asyncio.subprocess.PIPE,
229
+ env=env,
230
+ )
231
+ if self._startup_delay_s > 0:
232
+ await asyncio.sleep(self._startup_delay_s)
233
+ stderr_task = asyncio.create_task(_drain_stderr(proc, stderr_lines))
234
+ try:
235
+ yield proc, stderr_lines
236
+ except asyncio.CancelledError:
237
+ if proc.returncode is None:
238
+ try:
239
+ proc.kill()
240
+ except ProcessLookupError:
241
+ pass
242
+ raise
243
+ finally:
244
+ # Natural completion: child usually still has returncode=None until wait().
245
+ await proc.wait()
246
+ try:
247
+ await stderr_task
248
+ except asyncio.CancelledError:
249
+ pass
250
+ if xdg_tmpdir is not None:
251
+ shutil.rmtree(xdg_tmpdir, ignore_errors=True)
252
+
253
+ async def async_stream(
254
+ self,
255
+ prompt: str,
256
+ workspace: str | Path,
257
+ *,
258
+ run_cfg: RunConfig | None = None,
259
+ ) -> AsyncIterator[dict[str, Any]]:
260
+ """
261
+ Yield parsed JSON event dicts from stdout.
262
+
263
+ After the stream completes successfully, returns normally.
264
+ On non-zero exit, raises :class:`OpenCodeProcessError` (after all lines are yielded).
265
+ """
266
+ run_cfg = run_cfg or RunConfig()
267
+ validate_config_for_run(run_cfg)
268
+ bin_path = self.resolved_binary()
269
+ argv = build_argv(bin_path, prompt, run_cfg)
270
+ env = build_env(run_cfg)
271
+ cwd = str(Path(workspace).expanduser().resolve())
272
+
273
+ events_acc: list[dict[str, Any]] = []
274
+ raw_acc: list[str] = []
275
+
276
+ async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
277
+ async for line, ev in _stdout_line_event_iter(proc):
278
+ raw_acc.append(line)
279
+ events_acc.append(ev)
280
+ yield ev
281
+
282
+ code = proc.returncode if proc.returncode is not None else -1
283
+ stderr = "".join(stderr_lines)
284
+ if code != 0:
285
+ raise OpenCodeProcessError(
286
+ exit_code=code,
287
+ stderr=stderr,
288
+ events=events_acc,
289
+ raw_stdout_lines=raw_acc,
290
+ )
291
+
292
+ async def async_run(
293
+ self,
294
+ prompt: str,
295
+ workspace: str | Path,
296
+ *,
297
+ run_cfg: RunConfig | None = None,
298
+ timeout_s: float | None = None,
299
+ log_file: str | Path | None = None,
300
+ max_retries: int = 2,
301
+ retry_delay_s: float = 1.0,
302
+ ) -> RunResult:
303
+ """
304
+ Run to completion and return a :class:`RunResult`.
305
+
306
+ If ``log_file`` is given, each event dict is appended as a JSON line
307
+ during execution (flushed immediately), so partial progress survives
308
+ crashes.
309
+
310
+ Raises :class:`OpenCodeTimeoutError` if ``timeout_s`` elapses.
311
+
312
+ Parameters
313
+ ----------
314
+ max_retries:
315
+ Number of additional attempts when opencode crashes during SQLite
316
+ startup (WAL-pragma race). Set to ``0`` to disable retry.
317
+ retry_delay_s:
318
+ Seconds to wait between retry attempts.
319
+ """
320
+ run_cfg = run_cfg or RunConfig()
321
+
322
+ async def _inner() -> RunResult:
323
+ validate_config_for_run(run_cfg)
324
+ bin_path = self.resolved_binary()
325
+ argv = build_argv(bin_path, prompt, run_cfg)
326
+ env = build_env(run_cfg)
327
+ cwd = str(Path(workspace).expanduser().resolve())
328
+
329
+ events_acc: list[dict[str, Any]] = []
330
+ raw_acc: list[str] = []
331
+
332
+ log_fh = open(log_file, "w") if log_file is not None else None
333
+ try:
334
+ async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
335
+ async for line, ev in _stdout_line_event_iter(proc):
336
+ raw_acc.append(line)
337
+ events_acc.append(ev)
338
+ if log_fh is not None:
339
+ log_fh.write(json.dumps(ev, ensure_ascii=False) + "\n")
340
+ log_fh.flush()
341
+ finally:
342
+ if log_fh is not None:
343
+ log_fh.close()
344
+
345
+ code = proc.returncode if proc.returncode is not None else -1
346
+ stderr = "".join(stderr_lines)
347
+ if code != 0:
348
+ raise OpenCodeProcessError(
349
+ exit_code=code,
350
+ stderr=stderr,
351
+ events=events_acc,
352
+ raw_stdout_lines=raw_acc,
353
+ )
354
+ return aggregate_run_result(
355
+ events=events_acc,
356
+ raw_stdout_lines=raw_acc,
357
+ exit_code=code,
358
+ stderr=stderr,
359
+ )
360
+
361
+ async def _run_with_retries() -> RunResult:
362
+ last_exc: OpenCodeProcessError | None = None
363
+ for attempt in range(1 + max_retries):
364
+ if attempt > 0:
365
+ await asyncio.sleep(retry_delay_s)
366
+ try:
367
+ return await _inner()
368
+ except OpenCodeProcessError as exc:
369
+ if attempt < max_retries and _is_sqlite_startup_error(exc.stderr):
370
+ last_exc = exc
371
+ continue
372
+ raise
373
+ raise last_exc # type: ignore[misc] # unreachable; satisfies type checker
374
+
375
+ if timeout_s is not None:
376
+ try:
377
+ return await asyncio.wait_for(_run_with_retries(), timeout=timeout_s)
378
+ except asyncio.TimeoutError as e:
379
+ raise OpenCodeTimeoutError(
380
+ f"OpenCode run exceeded timeout_s={timeout_s!r}"
381
+ ) from e
382
+ return await _run_with_retries()
@@ -0,0 +1,109 @@
1
+ """Runtime config merge for ``OPENCODE_CONFIG_CONTENT`` and CLI flags."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ # Permission values accepted by OpenCode
11
+ PermissionAction = str # "allow" | "ask" | "deny"
12
+
13
+ # Nested permission maps: tool name -> action or pattern -> action
14
+ PermissionMap = dict[str, Any]
15
+
16
+
17
+ def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
18
+ out = dict(base)
19
+ for k, v in override.items():
20
+ if isinstance(v, Mapping) and not isinstance(v, (str, bytes, bytearray)):
21
+ existing = out.get(k)
22
+ if isinstance(existing, dict):
23
+ out[k] = _deep_merge(existing, dict(v))
24
+ else:
25
+ out[k] = _deep_merge({}, dict(v))
26
+ else:
27
+ out[k] = v
28
+ return out
29
+
30
+
31
+ @dataclass
32
+ class RunConfig:
33
+ """Per-invocation settings merged into env and CLI."""
34
+
35
+ agent: str | None = None
36
+ model: str | None = None
37
+ files: tuple[str | Path, ...] = ()
38
+ title: str | None = None
39
+ command: str | None = None
40
+ continue_session: bool = False
41
+ session_id: str | None = None
42
+ fork: bool = False
43
+ share: bool | None = None
44
+ attach: str | None = None
45
+ password: str | None = None
46
+ remote_dir: str | None = None
47
+ port: int | None = None
48
+ variant: str | None = None
49
+ thinking: bool | None = None
50
+ print_logs: bool | None = None
51
+ log_level: str | None = None
52
+ disable_autoupdate: bool = True
53
+ extra_env: Mapping[str, str] | None = None
54
+ # Injected as JSON via OPENCODE_CONFIG_CONTENT (merged with config_overrides)
55
+ permission: PermissionMap | None = None
56
+ mcp: dict[str, Any] | None = None
57
+ tools: dict[str, Any] | None = None
58
+ config_overrides: dict[str, Any] | None = None
59
+
60
+ def build_opencode_config_dict(self) -> dict[str, Any]:
61
+ """Build the dict serialized to ``OPENCODE_CONFIG_CONTENT``."""
62
+ merged: dict[str, Any] = {}
63
+ if self.config_overrides:
64
+ merged = _deep_merge(merged, self.config_overrides)
65
+ if self.permission is not None:
66
+ merged = _deep_merge(merged, {"permission": dict(self.permission)})
67
+ if self.mcp is not None:
68
+ merged = _deep_merge(merged, {"mcp": dict(self.mcp)})
69
+ if self.tools is not None:
70
+ merged = _deep_merge(merged, {"tools": dict(self.tools)})
71
+ return merged
72
+
73
+ def opencode_config_content_json(self) -> str | None:
74
+ cfg = self.build_opencode_config_dict()
75
+ if not cfg:
76
+ return None
77
+ return json.dumps(cfg, ensure_ascii=False)
78
+
79
+
80
+ def validate_permission_actions(obj: Any, *, _path: str = "") -> None:
81
+ """Ensure string leaves are non-interactive OpenCode permission actions.
82
+
83
+ ``"ask"`` is rejected because the subprocess has no terminal to prompt —
84
+ it would block forever.
85
+ """
86
+ allowed = frozenset({"allow", "deny"})
87
+ if isinstance(obj, str):
88
+ if obj == "ask":
89
+ loc = f" at {_path!r}" if _path else ""
90
+ raise ValueError(
91
+ f"Permission action 'ask' is not supported in non-interactive "
92
+ f"subprocess mode{loc}; use 'allow' or 'deny' instead"
93
+ )
94
+ if obj not in allowed:
95
+ raise ValueError(
96
+ f"Invalid permission action {obj!r}{' at ' + repr(_path) if _path else ''}; "
97
+ f"expected one of {sorted(allowed)}"
98
+ )
99
+ return
100
+ if isinstance(obj, dict):
101
+ for k, v in obj.items():
102
+ child_path = f"{_path}.{k}" if _path else k
103
+ validate_permission_actions(v, _path=child_path)
104
+
105
+
106
+ def validate_config_for_run(cfg: RunConfig) -> None:
107
+ """Strict checks before spawning."""
108
+ if cfg.permission is not None:
109
+ validate_permission_actions(cfg.permission)
@@ -0,0 +1,39 @@
1
+ """Exceptions raised by the OpenCode async client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ class OpenCodeError(Exception):
10
+ """Base error for OpenCode wrapper operations."""
11
+
12
+
13
+ class OpenCodeBinaryNotFoundError(OpenCodeError):
14
+ """The configured OpenCode executable path does not exist or is not a file."""
15
+
16
+
17
+ @dataclass
18
+ class OpenCodeProcessError(OpenCodeError):
19
+ """``opencode`` exited with a non-zero status or was killed."""
20
+
21
+ exit_code: int | None
22
+ stderr: str
23
+ events: list[dict[str, Any]] = field(default_factory=list)
24
+ raw_stdout_lines: list[str] = field(default_factory=list)
25
+
26
+ def __str__(self) -> str:
27
+ parts = [f"opencode exited with code {self.exit_code!r}"]
28
+ if self.stderr.strip():
29
+ tail = self.stderr.strip()[-2000:]
30
+ parts.append(f"stderr (tail):\n{tail}")
31
+ return "\n".join(parts)
32
+
33
+
34
+ class OpenCodeTimeoutError(OpenCodeError):
35
+ """The OpenCode subprocess did not finish within the configured timeout."""
36
+
37
+
38
+ class OpenCodeCancelledError(OpenCodeError):
39
+ """The run was cancelled (e.g. asyncio task cancellation)."""
@@ -0,0 +1,192 @@
1
+ """Parse ``opencode run --format json`` stdout lines and aggregate ``RunResult``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Iterator
8
+
9
+
10
+ def parse_event_line(line: str) -> dict[str, Any]:
11
+ """
12
+ Parse one stdout line into an event dict.
13
+
14
+ Non-JSON lines become a ``diagnostic`` event so the stream never breaks.
15
+ """
16
+ stripped = line.strip()
17
+ if not stripped:
18
+ return {"type": "diagnostic", "kind": "empty_line", "raw": line}
19
+ try:
20
+ obj = json.loads(stripped)
21
+ if isinstance(obj, dict):
22
+ return obj
23
+ return {"type": "diagnostic", "kind": "non_object_json", "value": obj}
24
+ except json.JSONDecodeError as e:
25
+ return {
26
+ "type": "diagnostic",
27
+ "kind": "json_decode_error",
28
+ "raw": stripped,
29
+ "error": str(e),
30
+ }
31
+
32
+
33
+ def iter_parse_lines(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
34
+ for line in lines:
35
+ yield parse_event_line(line)
36
+
37
+
38
+ def _text_from_event(ev: dict[str, Any]) -> str | None:
39
+ t = ev.get("type")
40
+ if t == "text":
41
+ # OpenCode nested: {"type":"text","part":{"type":"text","text":"..."}}
42
+ part = ev.get("part")
43
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
44
+ return part["text"]
45
+ # Flat shapes: {"type":"text","content":"..."} or {"text":"..."}
46
+ if "content" in ev and isinstance(ev["content"], str):
47
+ return ev["content"]
48
+ if "text" in ev and isinstance(ev["text"], str):
49
+ return ev["text"]
50
+ if "delta" in ev and isinstance(ev["delta"], str):
51
+ return ev["delta"]
52
+ if t in ("message", "assistant", "model"):
53
+ content = ev.get("content")
54
+ if isinstance(content, str):
55
+ return content
56
+ # OpenCode / provider streaming: content as list of parts
57
+ content = ev.get("content")
58
+ if isinstance(content, list):
59
+ parts: list[str] = []
60
+ for part in content:
61
+ if isinstance(part, dict):
62
+ if isinstance(part.get("text"), str):
63
+ parts.append(part["text"])
64
+ elif isinstance(part.get("content"), str):
65
+ parts.append(part["content"])
66
+ elif isinstance(part, str):
67
+ parts.append(part)
68
+ if parts:
69
+ return "".join(parts)
70
+ return None
71
+
72
+
73
+ def run_result_fuzzy_text(result: "RunResult") -> str:
74
+ """
75
+ Best-effort extract human-visible model output across varying ``--format json`` shapes.
76
+
77
+ Uses :attr:`RunResult.final_text` when non-empty; otherwise scans events and raw lines.
78
+ """
79
+ if (result.final_text or "").strip():
80
+ return result.final_text.strip()
81
+ pieces: list[str] = []
82
+ for ev in result.events:
83
+ if ev.get("type") == "diagnostic":
84
+ continue
85
+ chunk = _text_from_event(ev)
86
+ if chunk and chunk.strip():
87
+ pieces.append(chunk.strip())
88
+ continue
89
+ for key in ("content", "text", "delta", "output", "message", "result", "value"):
90
+ val = ev.get(key)
91
+ if isinstance(val, str) and val.strip():
92
+ pieces.append(val.strip())
93
+ msg = ev.get("message")
94
+ if isinstance(msg, dict):
95
+ for key in ("content", "text"):
96
+ v = msg.get(key)
97
+ if isinstance(v, str) and v.strip():
98
+ pieces.append(v.strip())
99
+ if pieces:
100
+ return "\n".join(pieces).strip()
101
+ raw = "\n".join(x.strip() for x in result.raw_stdout_lines if x.strip())
102
+ return raw.strip()
103
+
104
+
105
+ def _tool_summary(ev: dict[str, Any]) -> dict[str, Any] | None:
106
+ t = ev.get("type")
107
+ if t in ("tool_use", "tool_call", "tool_result", "tool"):
108
+ return {k: v for k, v in ev.items() if k != "type"} | {"type": t}
109
+ if t == "step_finish" and "tool" in ev:
110
+ return {"type": "step_tool", "payload": ev.get("tool")}
111
+ return None
112
+
113
+
114
+ @dataclass
115
+ class TokenUsage:
116
+ """Aggregated token counts across all steps."""
117
+
118
+ total: int = 0
119
+ input: int = 0
120
+ output: int = 0
121
+ reasoning: int = 0
122
+ cache_read: int = 0
123
+ cache_write: int = 0
124
+
125
+
126
+ @dataclass
127
+ class RunResult:
128
+ """Aggregated outcome of a completed ``opencode run``."""
129
+
130
+ events: list[dict[str, Any]] = field(default_factory=list)
131
+ final_text: str = ""
132
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
133
+ exit_code: int | None = None
134
+ stderr: str = ""
135
+ raw_stdout_lines: list[str] = field(default_factory=list)
136
+ token_usage: TokenUsage = field(default_factory=TokenUsage)
137
+ total_cost: float = 0.0
138
+ turns: int = 0
139
+
140
+ def append_event(self, ev: dict[str, Any]) -> None:
141
+ self.events.append(ev)
142
+ chunk = _text_from_event(ev)
143
+ if chunk:
144
+ self.final_text += chunk
145
+ tool = _tool_summary(ev)
146
+ if tool is not None:
147
+ self.tool_calls.append(tool)
148
+ if ev.get("type") == "step_finish":
149
+ self._accumulate_step(ev)
150
+
151
+ def _accumulate_step(self, ev: dict[str, Any]) -> None:
152
+ """Extract cost/token/turn info from a ``step_finish`` event."""
153
+ part = ev.get("part") or ev
154
+ cost = part.get("cost")
155
+ if isinstance(cost, (int, float)):
156
+ self.total_cost += cost
157
+ tokens = part.get("tokens")
158
+ if isinstance(tokens, dict):
159
+ u = self.token_usage
160
+ for attr, key in (
161
+ ("total", "total"),
162
+ ("input", "input"),
163
+ ("output", "output"),
164
+ ("reasoning", "reasoning"),
165
+ ):
166
+ val = tokens.get(key)
167
+ if isinstance(val, (int, float)):
168
+ setattr(u, attr, getattr(u, attr) + int(val))
169
+ cache = tokens.get("cache")
170
+ if isinstance(cache, dict):
171
+ for attr, key in (("cache_read", "read"), ("cache_write", "write")):
172
+ val = cache.get(key)
173
+ if isinstance(val, (int, float)):
174
+ setattr(u, attr, getattr(u, attr) + int(val))
175
+ self.turns += 1
176
+
177
+
178
+ def aggregate_run_result(
179
+ *,
180
+ events: list[dict[str, Any]],
181
+ raw_stdout_lines: list[str],
182
+ exit_code: int | None,
183
+ stderr: str,
184
+ ) -> RunResult:
185
+ r = RunResult(
186
+ raw_stdout_lines=list(raw_stdout_lines),
187
+ exit_code=exit_code,
188
+ stderr=stderr,
189
+ )
190
+ for ev in events:
191
+ r.append_event(ev)
192
+ return r
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-opencode-wrapper
3
+ Version: 0.1.2
4
+ Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
5
+ Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
6
+ Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
7
+ Project-URL: Issues, https://github.com/idailylife/oc_py_wrapper/issues
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
13
+
14
+ # oc-py-harness
15
+
16
+ Python **async** wrapper around the [OpenCode](https://opencode.ai/docs/) CLI (`opencode run --format json`). Intended as a subprocess-based executor for **multi-agent workflow** orchestration.
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.10+
21
+ - `opencode` on `PATH` (or pass an absolute path to the binary)
22
+
23
+ ## Install (local tree)
24
+
25
+ ```bash
26
+ pip install -e ".[dev]"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### One-shot run with aggregated result
32
+
33
+ ```python
34
+ import asyncio
35
+ from pathlib import Path
36
+
37
+ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
38
+
39
+ async def main():
40
+ client = AsyncOpenCodeClient("opencode")
41
+ cfg = RunConfig(
42
+ model="anthropic/claude-sonnet-4-5",
43
+ agent="plan",
44
+ permission={"bash": "deny", "edit": "deny"},
45
+ mcp={
46
+ "demo": {
47
+ "type": "local",
48
+ "command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
49
+ "enabled": True,
50
+ }
51
+ },
52
+ )
53
+ result = await client.async_run(
54
+ "Summarize the README in one sentence.",
55
+ Path("/path/to/repo"),
56
+ run_cfg=cfg,
57
+ timeout_s=600,
58
+ )
59
+ print(result.exit_code, result.final_text)
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ### Stream structured JSON events
65
+
66
+ ```python
67
+ async def stream_example():
68
+ client = AsyncOpenCodeClient()
69
+ cfg = RunConfig(permission={"*": "allow"})
70
+ async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
71
+ print(event)
72
+ ```
73
+
74
+ ### Parallel agents (`asyncio.gather`)
75
+
76
+ ```python
77
+ async def multi():
78
+ # startup_concurrency=1 serialises SQLite initialisation to avoid a known
79
+ # WAL-pragma race in opencode when many instances start simultaneously.
80
+ # startup_delay_s controls how long each slot is held before the next
81
+ # process is allowed to start (default 0.3 s).
82
+ client = AsyncOpenCodeClient(startup_concurrency=1, startup_delay_s=0.3)
83
+ ws = Path("/path/to/monorepo")
84
+ results = await asyncio.gather(*[
85
+ client.async_run(
86
+ f"Explain services/{svc}.",
87
+ ws / "services" / svc,
88
+ run_cfg=RunConfig(agent="explore"),
89
+ timeout_s=600,
90
+ # max_retries=2 (default): retry automatically if opencode crashes
91
+ # during SQLite startup before giving up.
92
+ )
93
+ for svc in ["api", "worker", "gateway"]
94
+ ])
95
+ return results
96
+ ```
97
+
98
+ ## Configuration injection
99
+
100
+ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
101
+
102
+ | Field | Purpose |
103
+ |--------|---------|
104
+ | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
105
+ | `mcp` | MCP server definitions |
106
+ | `tools` | Enable/disable tools (including MCP globs) |
107
+ | `config_overrides` | Any extra top-level config keys to deep-merge |
108
+
109
+ Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
110
+
111
+ ## CLI arguments
112
+
113
+ `RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
114
+
115
+ ## Tests
116
+
117
+ Unit tests (no real OpenCode / no API calls):
118
+
119
+ ```bash
120
+ pytest -q -m "not integration"
121
+ ```
122
+
123
+ Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
124
+
125
+ ```bash
126
+ pytest -m integration -q tests/test_integration_opencode.py
127
+ ```
128
+
129
+ **Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
130
+
131
+ ```bash
132
+ OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
133
+ ```
134
+
135
+ Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
136
+ Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
137
+
138
+ | Env | Meaning |
139
+ |-----|--------|
140
+ | `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
141
+ | `OPENCODE_INTEGRATION=0` | Skip integration tests |
142
+ | `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
143
+ | `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
144
+ | `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
145
+
146
+ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
147
+
148
+ ## Concurrency notes
149
+
150
+ When running many tasks with `asyncio.gather`, two mitigations are active by default:
151
+
152
+ **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
153
+
154
+ **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
155
+
156
+ Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
157
+
158
+ ## Notes
159
+
160
+ - Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
161
+ - For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
@@ -0,0 +1,9 @@
1
+ opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
2
+ opencode_wrapper/client.py,sha256=YY7K8R6AcNWrOw595KazcLf1Q--CJgHvt6IJNT9cvOQ,13846
3
+ opencode_wrapper/config.py,sha256=5vOlqc0YvPeFjmY9IH4DZYhhU3nKVp54Nt2KSeQaQUQ,3872
4
+ opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
5
+ opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
6
+ py_opencode_wrapper-0.1.2.dist-info/METADATA,sha256=oT5MX1VOFtda9apHKONfEe_scVz3Z0PdBiNhPVMGyNg,6012
7
+ py_opencode_wrapper-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ py_opencode_wrapper-0.1.2.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
9
+ py_opencode_wrapper-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ opencode_wrapper