py-opencode-wrapper 0.2.1__py3-none-any.whl → 0.3.0__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.
@@ -16,9 +16,11 @@ from opencode_wrapper.events import (
16
16
  parse_event_line,
17
17
  run_result_fuzzy_text,
18
18
  )
19
+ from opencode_wrapper.session import OpenCodeSession
19
20
 
20
21
  __all__ = [
21
22
  "AsyncOpenCodeClient",
23
+ "OpenCodeSession",
22
24
  "RunConfig",
23
25
  "RunResult",
24
26
  "TokenUsage",
@@ -73,7 +73,7 @@ def build_argv(
73
73
  cmd.extend(["--port", str(run_cfg.port)])
74
74
  if run_cfg.variant:
75
75
  cmd.extend(["--variant", run_cfg.variant])
76
- if run_cfg.thinking is True:
76
+ if run_cfg.record_thinking is True or run_cfg.thinking is True:
77
77
  cmd.append("--thinking")
78
78
 
79
79
  if prompt:
@@ -313,15 +313,23 @@ class AsyncOpenCodeClient:
313
313
  cwd: str,
314
314
  env: dict[str, str],
315
315
  run_cfg: RunConfig,
316
+ data_home: str | None = None,
316
317
  ) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
317
318
  stderr_lines: list[str] = []
318
319
  cleanup_tmpdirs: list[str] = []
319
320
  # Give each process its own XDG_DATA_HOME so opencode.db is isolated.
320
321
  # Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
321
322
  # 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)
323
+ # When *data_home* is provided the caller owns a persistent dir (e.g. an
324
+ # OpenCodeSession reusing one DB across turns), so it is NOT added to
325
+ # cleanup_tmpdirs — the caller deletes it when done.
326
+ managed = data_home is not None
327
+ if self._isolate_db or managed:
328
+ if managed:
329
+ xdg_tmpdir = data_home # type: ignore[assignment]
330
+ else:
331
+ xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
332
+ cleanup_tmpdirs.append(xdg_tmpdir)
325
333
  # Symlink auth.json so provider API keys (stored by `opencode auth`)
326
334
  # are visible in the isolated data dir. Without this, providers
327
335
  # that rely on auth.json (rather than env-var keys) fail with
@@ -331,7 +339,9 @@ class AsyncOpenCodeClient:
331
339
  if real_auth.is_file():
332
340
  iso_oc_dir = Path(xdg_tmpdir) / "opencode"
333
341
  iso_oc_dir.mkdir(parents=True, exist_ok=True)
334
- (iso_oc_dir / "auth.json").symlink_to(real_auth)
342
+ link = iso_oc_dir / "auth.json"
343
+ if not link.exists(): # reused across turns — guard re-symlink
344
+ link.symlink_to(real_auth)
335
345
  env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
336
346
  # When the caller has not opted into host-config inheritance (the
337
347
  # default), redirect XDG_CONFIG_HOME / OPENCODE_TEST_HOME at a sanitized
@@ -425,6 +435,7 @@ class AsyncOpenCodeClient:
425
435
  log_file: str | Path | None = None,
426
436
  max_retries: int = 2,
427
437
  retry_delay_s: float = 1.0,
438
+ data_home: str | None = None,
428
439
  ) -> RunResult:
429
440
  """
430
441
  Run to completion and return a :class:`RunResult`.
@@ -457,7 +468,7 @@ class AsyncOpenCodeClient:
457
468
 
458
469
  log_fh = open(log_file, "w") if log_file is not None else None
459
470
  try:
460
- async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
471
+ async with self._managed_process(argv, cwd, env, run_cfg, data_home=data_home) as (proc, stderr_lines):
461
472
  async for line, ev in _stdout_line_event_iter(proc):
462
473
  raw_acc.append(line)
463
474
  events_acc.append(ev)
@@ -147,6 +147,10 @@ class RunConfig:
147
147
  remote_dir: str | None = None
148
148
  port: int | None = None
149
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.
150
154
  thinking: bool | None = None
151
155
  print_logs: bool | None = None
152
156
  log_level: str | None = 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
@@ -0,0 +1,90 @@
1
+ """Stateful multi-turn conversation over a single opencode session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from opencode_wrapper.config import RunConfig
12
+ from opencode_wrapper.events import RunResult
13
+
14
+ if TYPE_CHECKING:
15
+ from opencode_wrapper.client import AsyncOpenCodeClient
16
+
17
+ _UNSET: object = object()
18
+
19
+
20
+ class OpenCodeSession:
21
+ """Multi-turn conversation backed by one persistent opencode session.
22
+
23
+ On ``__aenter__`` the session allocates a private ``XDG_DATA_HOME`` tmpdir.
24
+ Every :meth:`send` reuses it, so opencode's SQLite session DB survives across
25
+ turns — the first turn creates the session, later turns continue it via
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__``.
28
+
29
+ Parameters
30
+ ----------
31
+ client:
32
+ The :class:`AsyncOpenCodeClient` used to spawn each turn.
33
+ workspace:
34
+ Project directory passed to every ``opencode run``.
35
+ run_cfg:
36
+ Base config applied to each turn; ``session_id`` is injected automatically
37
+ after the first turn. A per-call override may be passed to :meth:`send`.
38
+ timeout_s:
39
+ Default per-turn timeout; overridable per :meth:`send` call.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ client: "AsyncOpenCodeClient",
45
+ workspace: str | Path,
46
+ *,
47
+ run_cfg: RunConfig | None = None,
48
+ timeout_s: float | None = None,
49
+ ) -> None:
50
+ self._client = client
51
+ self._workspace = workspace
52
+ self._base_cfg = run_cfg or RunConfig()
53
+ self._timeout_s = timeout_s
54
+ self._data_home: str | None = None
55
+ self.session_id: str | None = None
56
+
57
+ async def __aenter__(self) -> "OpenCodeSession":
58
+ self._data_home = tempfile.mkdtemp(prefix="oc_session_")
59
+ return self
60
+
61
+ async def __aexit__(self, *exc: object) -> None:
62
+ if self._data_home is not None:
63
+ shutil.rmtree(self._data_home, ignore_errors=True)
64
+ self._data_home = None
65
+
66
+ async def send(
67
+ self,
68
+ prompt: str,
69
+ *,
70
+ run_cfg: RunConfig | None = None,
71
+ timeout_s: float | object = _UNSET,
72
+ ) -> RunResult:
73
+ """Run one turn and return its :class:`RunResult`, continuing the session."""
74
+ if self._data_home is None:
75
+ raise RuntimeError(
76
+ "OpenCodeSession.send() must be called inside 'async with'"
77
+ )
78
+ cfg = run_cfg or self._base_cfg
79
+ if self.session_id:
80
+ cfg = dataclasses.replace(cfg, session_id=self.session_id)
81
+ result = await self._client.async_run(
82
+ prompt,
83
+ self._workspace,
84
+ run_cfg=cfg,
85
+ timeout_s=self._timeout_s if timeout_s is _UNSET else timeout_s, # type: ignore[arg-type]
86
+ data_home=self._data_home,
87
+ )
88
+ if self.session_id is None and result.session_id:
89
+ self.session_id = result.session_id
90
+ return result
@@ -1,26 +1,42 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-opencode-wrapper
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
7
7
  Project-URL: Issues, https://github.com/idailylife/oc_py_wrapper/issues
8
8
  Requires-Python: >=3.8
9
9
  Description-Content-Type: text/markdown
10
+ License-File: LICENSE
10
11
  Provides-Extra: dev
11
12
  Requires-Dist: pytest>=8; extra == "dev"
12
13
  Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
14
+ Dynamic: license-file
13
15
 
14
- # oc-py-harness
16
+ # py-opencode-wrapper
15
17
 
16
18
  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
19
 
18
20
  ## Requirements
19
21
 
20
- - Python 3.10+
22
+ - Python 3.8+
21
23
  - `opencode` on `PATH` (or pass an absolute path to the binary)
22
24
 
23
- ## Install (local tree)
25
+ ## Install
26
+
27
+ From PyPI (most users):
28
+
29
+ ```bash
30
+ pip install py-opencode-wrapper
31
+ ```
32
+
33
+ The distribution name on PyPI is `py-opencode-wrapper`; import it as `opencode_wrapper`:
34
+
35
+ ```python
36
+ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
37
+ ```
38
+
39
+ For local development (editable install with test deps):
24
40
 
25
41
  ```bash
26
42
  pip install -e ".[dev]"
@@ -39,7 +55,7 @@ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
39
55
  async def main():
40
56
  client = AsyncOpenCodeClient("opencode")
41
57
  cfg = RunConfig(
42
- model="anthropic/claude-sonnet-4-5",
58
+ model="opencode/big-pickle",
43
59
  agent="plan",
44
60
  permission={"bash": "deny", "edit": "deny"},
45
61
  mcp={
@@ -61,6 +77,40 @@ async def main():
61
77
  asyncio.run(main())
62
78
  ```
63
79
 
80
+ Set `RunConfig(record_thinking=True)` when you want OpenCode reasoning/thinking
81
+ parts included in `result.events` and `log_file` JSON lines. This only maps to
82
+ OpenCode's display/output flag `--thinking`; it does not change model reasoning
83
+ effort. Use `variant` separately if you intentionally want a provider-specific
84
+ reasoning effort.
85
+
86
+ ### Multi-turn conversation (`OpenCodeSession`)
87
+
88
+ For a stateful, multi-turn chat over a single opencode session, use
89
+ `OpenCodeSession` as an async context manager. Each `send()` continues the same
90
+ session, so the model retains context across turns:
91
+
92
+ ```python
93
+ import asyncio
94
+ from opencode_wrapper import AsyncOpenCodeClient, OpenCodeSession, RunConfig
95
+
96
+ async def chat():
97
+ client = AsyncOpenCodeClient()
98
+ async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
99
+ r1 = await s.send("My name is Bob.")
100
+ r2 = await s.send("What is my name?") # auto-continues → "Bob"
101
+ print(s.session_id, r2.final_text)
102
+
103
+ asyncio.run(chat())
104
+ ```
105
+
106
+ On enter, the session allocates a private, persistent `XDG_DATA_HOME` tmpdir that
107
+ every turn reuses, so opencode's SQLite session DB survives across turns. The
108
+ first `send()` creates the session (its id is captured on `RunResult.session_id`);
109
+ later turns continue it via `--session <id>`. Each session is an isolated island —
110
+ no shared global DB, so no cross-session lock contention — and the tmpdir is
111
+ removed when the `async with` block exits. `send()` accepts per-turn `run_cfg` and
112
+ `timeout_s` overrides.
113
+
64
114
  ### Stream structured JSON events
65
115
 
66
116
  ```python
@@ -75,11 +125,7 @@ async def stream_example():
75
125
 
76
126
  ```python
77
127
  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)
128
+ client = AsyncOpenCodeClient()
83
129
  ws = Path("/path/to/monorepo")
84
130
  results = await asyncio.gather(*[
85
131
  client.async_run(
@@ -87,14 +133,14 @@ async def multi():
87
133
  ws / "services" / svc,
88
134
  run_cfg=RunConfig(agent="explore"),
89
135
  timeout_s=600,
90
- # max_retries=2 (default): retry automatically if opencode crashes
91
- # during SQLite startup before giving up.
92
136
  )
93
137
  for svc in ["api", "worker", "gateway"]
94
138
  ])
95
139
  return results
96
140
  ```
97
141
 
142
+ Safe defaults for parallel runs (startup serialisation, private SQLite DB per run, and automatic retry on SQLite-startup crashes) are enabled out of the box — most users don't need to tune them. See *Concurrency notes* below if you want to.
143
+
98
144
  ## Configuration injection
99
145
 
100
146
  Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
@@ -166,15 +212,15 @@ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without
166
212
 
167
213
  ## Concurrency notes
168
214
 
169
- When running many tasks with `asyncio.gather`, three protections are enabled by default:
170
-
171
- **Startup serialisation** — `startup_concurrency=1` and `startup_delay_s=0.3` limit how many processes enter SQLite startup at once, reducing WAL-initialisation race crashes.
215
+ The defaults already handle the common pitfalls when running many `async_run` calls in parallel you usually don't need to touch any of these.
172
216
 
173
- **DB isolation** `isolate_db=True` gives each run a private `XDG_DATA_HOME`, so concurrent runs do not contend on the same `opencode.db` during tool execution.
217
+ - **Startup serialisation** (`startup_concurrency=1`, `startup_delay_s=0.3`) spaces out SQLite WAL initialisation across processes to avoid a startup race in `opencode`.
218
+ - **DB isolation** (`isolate_db=True`) — each run gets its own `XDG_DATA_HOME`, so concurrent runs don't share `opencode.db` and serialise on SQLite write locks during tool execution.
219
+ - **Automatic retry** (`async_run(max_retries=2, retry_delay_s=1.0)`) — retries known SQLite-startup crashes with short backoff. Non-SQLite failures still fail fast.
174
220
 
175
- **Automatic retry** `async_run(max_retries=2, retry_delay_s=1.0)` retries known SQLite-startup crashes with short backoff. Non-SQLite failures still fail fast.
221
+ 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.
176
222
 
177
- Set `startup_concurrency=0`, `isolate_db=False`, and `max_retries=0` to opt out.
223
+ > For **multi-turn conversations** you don't need `isolate_db=False`. Use [`OpenCodeSession`](#multi-turn-conversation-opencodesession) instead it keeps one session's DB alive across turns in a private dir, so context is preserved without sharing the global DB.
178
224
 
179
225
  ## Notes
180
226
 
@@ -0,0 +1,11 @@
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,,
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 0x0000ffff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +0,0 @@
1
- opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
2
- opencode_wrapper/client.py,sha256=Ny4pDBV6cToIOn2W7BgCFL1w12KdlVQVCInhddf_Df4,19546
3
- opencode_wrapper/config.py,sha256=JraBPkX95GXFoOvn181BRv_8YPvsUDXiZMN1hZWhSf0,7823
4
- opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
5
- opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
6
- py_opencode_wrapper-0.2.1.dist-info/METADATA,sha256=W56IyS4yoBlsSFIIrtsPx0-Ck6AoCCuhmdHiZslRgnE,6884
7
- py_opencode_wrapper-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
- py_opencode_wrapper-0.2.1.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
9
- py_opencode_wrapper-0.2.1.dist-info/RECORD,,