py-opencode-wrapper 0.2.2__tar.gz → 0.3.1__tar.gz
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.
- py_opencode_wrapper-0.3.1/LICENSE +21 -0
- py_opencode_wrapper-0.3.1/PKG-INFO +294 -0
- py_opencode_wrapper-0.3.1/README.md +279 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/__init__.py +13 -1
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/client.py +39 -37
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/config.py +42 -28
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/events.py +131 -0
- py_opencode_wrapper-0.3.1/opencode_wrapper/server.py +255 -0
- py_opencode_wrapper-0.3.1/opencode_wrapper/session.py +258 -0
- py_opencode_wrapper-0.3.1/py_opencode_wrapper.egg-info/PKG-INFO +294 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/SOURCES.txt +6 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/pyproject.toml +1 -1
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_client_async.py +50 -11
- py_opencode_wrapper-0.3.1/tests/test_integration_server_session.py +146 -0
- py_opencode_wrapper-0.3.1/tests/test_server.py +165 -0
- py_opencode_wrapper-0.3.1/tests/test_session.py +366 -0
- py_opencode_wrapper-0.2.2/PKG-INFO +0 -188
- py_opencode_wrapper-0.2.2/README.md +0 -175
- py_opencode_wrapper-0.2.2/py_opencode_wrapper.egg-info/PKG-INFO +0 -188
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/errors.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/requires.txt +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/setup.cfg +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_config_instructions.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_config_permission.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_event_parser.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_external_directory.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_instructions.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_multi_agent_weather.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_opencode.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_parallel.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_run_result_fuzzy_text.py +0 -0
- {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_user_config_isolation.py +0 -0
|
@@ -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.
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-opencode-wrapper
|
|
3
|
+
Version: 0.3.1
|
|
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.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# py-opencode-wrapper
|
|
17
|
+
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Python 3.8+
|
|
23
|
+
- `opencode` on `PATH` (or pass an absolute path to the binary)
|
|
24
|
+
|
|
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):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### One-shot run with aggregated result
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
from opencode_wrapper import AsyncOpenCodeClient, RunConfig
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
client = AsyncOpenCodeClient("opencode")
|
|
57
|
+
cfg = RunConfig(
|
|
58
|
+
model="opencode/big-pickle",
|
|
59
|
+
agent="plan",
|
|
60
|
+
permission={"bash": "deny", "edit": "deny"},
|
|
61
|
+
mcp={
|
|
62
|
+
"demo": {
|
|
63
|
+
"type": "local",
|
|
64
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
|
|
65
|
+
"enabled": True,
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
result = await client.async_run(
|
|
70
|
+
"Summarize the README in one sentence.",
|
|
71
|
+
Path("/path/to/repo"),
|
|
72
|
+
run_cfg=cfg,
|
|
73
|
+
timeout_s=600,
|
|
74
|
+
)
|
|
75
|
+
print(result.exit_code, result.final_text)
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
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.
|
|
87
|
+
|
|
88
|
+
### Multi-turn conversation (`OpenCodeSession`)
|
|
89
|
+
|
|
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:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import asyncio
|
|
98
|
+
from opencode_wrapper import AsyncOpenCodeClient, OpenCodeSession, RunConfig
|
|
99
|
+
|
|
100
|
+
async def chat():
|
|
101
|
+
client = AsyncOpenCodeClient()
|
|
102
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
|
|
103
|
+
r1 = await s.send("My name is Bob.")
|
|
104
|
+
r2 = await s.send("What is my name?") # continues natively → "Bob"
|
|
105
|
+
print(s.session_id, r2.final_text)
|
|
106
|
+
|
|
107
|
+
asyncio.run(chat())
|
|
108
|
+
```
|
|
109
|
+
|
|
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.
|
|
163
|
+
|
|
164
|
+
### Stream structured JSON events
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
async def stream_example():
|
|
168
|
+
client = AsyncOpenCodeClient()
|
|
169
|
+
cfg = RunConfig(permission={"*": "allow"})
|
|
170
|
+
async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
|
|
171
|
+
print(event)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Parallel agents (`asyncio.gather`)
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
async def multi():
|
|
178
|
+
client = AsyncOpenCodeClient()
|
|
179
|
+
ws = Path("/path/to/monorepo")
|
|
180
|
+
results = await asyncio.gather(*[
|
|
181
|
+
client.async_run(
|
|
182
|
+
f"Explain services/{svc}.",
|
|
183
|
+
ws / "services" / svc,
|
|
184
|
+
run_cfg=RunConfig(agent="explore"),
|
|
185
|
+
timeout_s=600,
|
|
186
|
+
)
|
|
187
|
+
for svc in ["api", "worker", "gateway"]
|
|
188
|
+
])
|
|
189
|
+
return results
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
## Configuration injection
|
|
195
|
+
|
|
196
|
+
Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
|
|
197
|
+
|
|
198
|
+
| Field | Purpose |
|
|
199
|
+
|--------|---------|
|
|
200
|
+
| `permission` | `permission` map (`allow` / `deny`, patterns) |
|
|
201
|
+
| `mcp` | MCP server definitions |
|
|
202
|
+
| `tools` | Enable/disable tools (including MCP globs) |
|
|
203
|
+
| `instructions` | Instruction file paths / glob patterns to inject |
|
|
204
|
+
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
205
|
+
|
|
206
|
+
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
207
|
+
Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
|
|
208
|
+
|
|
209
|
+
### User config isolation
|
|
210
|
+
|
|
211
|
+
By default, `RunConfig.inherit_user_config=False` makes each child `opencode`
|
|
212
|
+
process see a sanitized copy of the host's global OpenCode config. The wrapper
|
|
213
|
+
keeps only provider-selection keys (`$schema`, `provider`,
|
|
214
|
+
`disabled_providers`, `enabled_providers`) and drops capability/configuration
|
|
215
|
+
keys such as `mcp`, `agent`, `command`, `tools`, `plugin`, `skills`,
|
|
216
|
+
`instructions`, `permission`, and `model`.
|
|
217
|
+
|
|
218
|
+
This keeps benchmark and orchestration runs reproducible while still allowing
|
|
219
|
+
provider configuration and `opencode auth` credentials to work. Project-level
|
|
220
|
+
config discovered from the workspace is not suppressed.
|
|
221
|
+
|
|
222
|
+
Set `inherit_user_config=True` to restore the legacy behavior of inheriting the
|
|
223
|
+
host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
224
|
+
`mcp`, `tools`, and `instructions` explicitly through `RunConfig`.
|
|
225
|
+
|
|
226
|
+
## CLI arguments
|
|
227
|
+
|
|
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).
|
|
245
|
+
|
|
246
|
+
## Tests
|
|
247
|
+
|
|
248
|
+
Unit tests (no real OpenCode / no API calls):
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
pytest -q -m "not integration"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pytest -m integration -q tests/test_integration_opencode.py
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
|
|
267
|
+
Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
|
|
268
|
+
|
|
269
|
+
| Env | Meaning |
|
|
270
|
+
|-----|--------|
|
|
271
|
+
| `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
|
|
272
|
+
| `OPENCODE_INTEGRATION=0` | Skip integration tests |
|
|
273
|
+
| `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
|
|
274
|
+
| `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
|
|
275
|
+
| `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
|
|
276
|
+
|
|
277
|
+
Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
|
|
278
|
+
|
|
279
|
+
## Concurrency notes
|
|
280
|
+
|
|
281
|
+
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.
|
|
282
|
+
|
|
283
|
+
- **Startup serialisation** (`startup_concurrency=1`, `startup_delay_s=0.3`) — spaces out SQLite WAL initialisation across processes to avoid a startup race in `opencode`.
|
|
284
|
+
- **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.
|
|
285
|
+
- **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.
|
|
286
|
+
|
|
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.
|
|
288
|
+
|
|
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.
|
|
290
|
+
|
|
291
|
+
## Notes
|
|
292
|
+
|
|
293
|
+
- Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
|
|
294
|
+
- For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# py-opencode-wrapper
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.8+
|
|
8
|
+
- `opencode` on `PATH` (or pass an absolute path to the binary)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
From PyPI (most users):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install py-opencode-wrapper
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The distribution name on PyPI is `py-opencode-wrapper`; import it as `opencode_wrapper`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from opencode_wrapper import AsyncOpenCodeClient, RunConfig
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For local development (editable install with test deps):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e ".[dev]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### One-shot run with aggregated result
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from opencode_wrapper import AsyncOpenCodeClient, RunConfig
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
client = AsyncOpenCodeClient("opencode")
|
|
42
|
+
cfg = RunConfig(
|
|
43
|
+
model="opencode/big-pickle",
|
|
44
|
+
agent="plan",
|
|
45
|
+
permission={"bash": "deny", "edit": "deny"},
|
|
46
|
+
mcp={
|
|
47
|
+
"demo": {
|
|
48
|
+
"type": "local",
|
|
49
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
|
|
50
|
+
"enabled": True,
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
result = await client.async_run(
|
|
55
|
+
"Summarize the README in one sentence.",
|
|
56
|
+
Path("/path/to/repo"),
|
|
57
|
+
run_cfg=cfg,
|
|
58
|
+
timeout_s=600,
|
|
59
|
+
)
|
|
60
|
+
print(result.exit_code, result.final_text)
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Set `RunConfig(cli_kwargs={"thinking": True})` when you want OpenCode
|
|
66
|
+
reasoning/thinking parts included in `result.events` and `log_file` JSON lines in
|
|
67
|
+
**run mode**. This maps to OpenCode's display/output flag `--thinking`; it does
|
|
68
|
+
not change model reasoning effort. In **server/session mode** there is no
|
|
69
|
+
`--thinking` equivalent — reasoning parts are produced per the model's reasoning
|
|
70
|
+
config and streamed onto the SSE bus unconditionally, so they already land in
|
|
71
|
+
`result.events` / `log_file` with no opt-in.
|
|
72
|
+
|
|
73
|
+
### Multi-turn conversation (`OpenCodeSession`)
|
|
74
|
+
|
|
75
|
+
For a stateful, multi-turn chat, use `OpenCodeSession` as an async context
|
|
76
|
+
manager. Unlike the one-shot `async_run`/`async_stream` (which spawn
|
|
77
|
+
`opencode run` per call), a session owns a headless `opencode serve` process for
|
|
78
|
+
the duration of the `async with` block and re-prompts one server-side session, so
|
|
79
|
+
the model retains context **natively** across turns:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import asyncio
|
|
83
|
+
from opencode_wrapper import AsyncOpenCodeClient, OpenCodeSession, RunConfig
|
|
84
|
+
|
|
85
|
+
async def chat():
|
|
86
|
+
client = AsyncOpenCodeClient()
|
|
87
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
|
|
88
|
+
r1 = await s.send("My name is Bob.")
|
|
89
|
+
r2 = await s.send("What is my name?") # continues natively → "Bob"
|
|
90
|
+
print(s.session_id, r2.final_text)
|
|
91
|
+
|
|
92
|
+
asyncio.run(chat())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
On enter, the session spawns `opencode serve` (with the same hermetic isolation
|
|
96
|
+
run mode uses) and creates one session pinned to the workspace; on exit the
|
|
97
|
+
session is deleted and the server torn down. `send()` accepts per-turn `run_cfg`
|
|
98
|
+
and `timeout_s` overrides, but only **prompt-body knobs** (`model` / `agent` /
|
|
99
|
+
`tools`) vary per turn — `permission` / `mcp` / `instructions` are fixed at enter
|
|
100
|
+
(they are server-global).
|
|
101
|
+
|
|
102
|
+
#### Human-in-the-loop permissions
|
|
103
|
+
|
|
104
|
+
Because the server can pause on a permission request, sessions support an
|
|
105
|
+
`on_permission` async callback that run mode cannot. Set `permission={"bash":
|
|
106
|
+
"ask"}` and answer each prompt with `"once"` / `"always"` / `"reject"`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
async def approve(props): # props: {"id", "sessionID", "permission", ...}
|
|
110
|
+
return "once"
|
|
111
|
+
|
|
112
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(permission={"bash": "ask"}),
|
|
113
|
+
on_permission=approve) as s:
|
|
114
|
+
r = await s.send("Run `echo hi` and tell me the output.")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
When `on_permission` is `None` (the default), any `permission.asked` is
|
|
118
|
+
auto-rejected so a turn never blocks. File attachments are run-mode only — pass
|
|
119
|
+
`RunConfig(cli_kwargs={"f": ["a.txt", "b.png"]})` to `async_run`. Server-mode
|
|
120
|
+
sessions ignore `cli_kwargs`, so embed file content in the prompt instead.
|
|
121
|
+
|
|
122
|
+
#### Answering the model's questions
|
|
123
|
+
|
|
124
|
+
opencode's built-in `question` tool lets the model ask the user multiple-choice
|
|
125
|
+
questions mid-run (gather preferences, clarify, offer choices). Pass an
|
|
126
|
+
`on_question` async callback to answer it. The callback receives the question
|
|
127
|
+
props (`{"id", "sessionID", "questions": [{"question", "header", "options":
|
|
128
|
+
[{"label", "description"}], "multiple"?, "custom"?}], ...}`) and returns a list
|
|
129
|
+
with one entry per question — each a list of selected option labels. Returning
|
|
130
|
+
`None` rejects (dismisses) the question.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
async def answer(props):
|
|
134
|
+
out = []
|
|
135
|
+
for q in props["questions"]:
|
|
136
|
+
out.append([q["options"][0]["label"]]) # pick the first option
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle"),
|
|
140
|
+
on_question=answer) as s:
|
|
141
|
+
r = await s.send("Ask me which database to use, then scaffold it.")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
When `on_question` is `None` (the default), any `question.asked` is auto-rejected
|
|
145
|
+
so a turn never blocks. The `question` tool is enabled by default under
|
|
146
|
+
`opencode serve`; set `RunConfig(extra_env={"OPENCODE_ENABLE_QUESTION_TOOL": "1"})`
|
|
147
|
+
to force-enable it regardless of the server's client identity.
|
|
148
|
+
|
|
149
|
+
### Stream structured JSON events
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
async def stream_example():
|
|
153
|
+
client = AsyncOpenCodeClient()
|
|
154
|
+
cfg = RunConfig(permission={"*": "allow"})
|
|
155
|
+
async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
|
|
156
|
+
print(event)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Parallel agents (`asyncio.gather`)
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
async def multi():
|
|
163
|
+
client = AsyncOpenCodeClient()
|
|
164
|
+
ws = Path("/path/to/monorepo")
|
|
165
|
+
results = await asyncio.gather(*[
|
|
166
|
+
client.async_run(
|
|
167
|
+
f"Explain services/{svc}.",
|
|
168
|
+
ws / "services" / svc,
|
|
169
|
+
run_cfg=RunConfig(agent="explore"),
|
|
170
|
+
timeout_s=600,
|
|
171
|
+
)
|
|
172
|
+
for svc in ["api", "worker", "gateway"]
|
|
173
|
+
])
|
|
174
|
+
return results
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
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.
|
|
178
|
+
|
|
179
|
+
## Configuration injection
|
|
180
|
+
|
|
181
|
+
Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
|
|
182
|
+
|
|
183
|
+
| Field | Purpose |
|
|
184
|
+
|--------|---------|
|
|
185
|
+
| `permission` | `permission` map (`allow` / `deny`, patterns) |
|
|
186
|
+
| `mcp` | MCP server definitions |
|
|
187
|
+
| `tools` | Enable/disable tools (including MCP globs) |
|
|
188
|
+
| `instructions` | Instruction file paths / glob patterns to inject |
|
|
189
|
+
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
190
|
+
|
|
191
|
+
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
192
|
+
Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
|
|
193
|
+
|
|
194
|
+
### User config isolation
|
|
195
|
+
|
|
196
|
+
By default, `RunConfig.inherit_user_config=False` makes each child `opencode`
|
|
197
|
+
process see a sanitized copy of the host's global OpenCode config. The wrapper
|
|
198
|
+
keeps only provider-selection keys (`$schema`, `provider`,
|
|
199
|
+
`disabled_providers`, `enabled_providers`) and drops capability/configuration
|
|
200
|
+
keys such as `mcp`, `agent`, `command`, `tools`, `plugin`, `skills`,
|
|
201
|
+
`instructions`, `permission`, and `model`.
|
|
202
|
+
|
|
203
|
+
This keeps benchmark and orchestration runs reproducible while still allowing
|
|
204
|
+
provider configuration and `opencode auth` credentials to work. Project-level
|
|
205
|
+
config discovered from the workspace is not suppressed.
|
|
206
|
+
|
|
207
|
+
Set `inherit_user_config=True` to restore the legacy behavior of inheriting the
|
|
208
|
+
host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
209
|
+
`mcp`, `tools`, and `instructions` explicitly through `RunConfig`.
|
|
210
|
+
|
|
211
|
+
## CLI arguments
|
|
212
|
+
|
|
213
|
+
In run mode, `model` and `agent` map to `-m` and `--agent`. Every other
|
|
214
|
+
`opencode run` flag is passed through `RunConfig.cli_kwargs`, a raw dict expanded
|
|
215
|
+
by `build_argv`:
|
|
216
|
+
|
|
217
|
+
- bool `True` → `--flag` (e.g. `{"fork": True}` → `--fork`)
|
|
218
|
+
- a value → `--flag=value` (e.g. `{"title": "demo"}` → `--title=demo`)
|
|
219
|
+
- a single-char key → `-k value` (e.g. `{"f": "a.txt"}` → `-f a.txt`)
|
|
220
|
+
- a list/tuple → repeated (e.g. `{"f": ["a.txt", "b.txt"]}` → `-f a.txt -f b.txt`)
|
|
221
|
+
- `False` / `None` → skipped
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
RunConfig(model="anthropic/claude", cli_kwargs={"fork": True, "title": "demo", "f": ["a.txt"]})
|
|
225
|
+
# -> opencode run --format json -m anthropic/claude --fork --title=demo -f a.txt <prompt>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Prompt text is appended as the final `opencode run` message argument.
|
|
229
|
+
`cli_kwargs` is ignored by `OpenCodeSession` (server mode has no CLI surface).
|
|
230
|
+
|
|
231
|
+
## Tests
|
|
232
|
+
|
|
233
|
+
Unit tests (no real OpenCode / no API calls):
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
pytest -q -m "not integration"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pytest -m integration -q tests/test_integration_opencode.py
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
|
|
252
|
+
Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
|
|
253
|
+
|
|
254
|
+
| Env | Meaning |
|
|
255
|
+
|-----|--------|
|
|
256
|
+
| `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
|
|
257
|
+
| `OPENCODE_INTEGRATION=0` | Skip integration tests |
|
|
258
|
+
| `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
|
|
259
|
+
| `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
|
|
260
|
+
| `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
|
|
261
|
+
|
|
262
|
+
Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
|
|
263
|
+
|
|
264
|
+
## Concurrency notes
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
268
|
+
- **Startup serialisation** (`startup_concurrency=1`, `startup_delay_s=0.3`) — spaces out SQLite WAL initialisation across processes to avoid a startup race in `opencode`.
|
|
269
|
+
- **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.
|
|
270
|
+
- **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.
|
|
271
|
+
|
|
272
|
+
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.
|
|
273
|
+
|
|
274
|
+
> 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.
|
|
275
|
+
|
|
276
|
+
## Notes
|
|
277
|
+
|
|
278
|
+
- Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
|
|
279
|
+
- For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
|
|
@@ -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,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",
|