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.
Files changed (34) hide show
  1. py_opencode_wrapper-0.3.1/LICENSE +21 -0
  2. py_opencode_wrapper-0.3.1/PKG-INFO +294 -0
  3. py_opencode_wrapper-0.3.1/README.md +279 -0
  4. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/__init__.py +13 -1
  5. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/client.py +39 -37
  6. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/config.py +42 -28
  7. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/events.py +131 -0
  8. py_opencode_wrapper-0.3.1/opencode_wrapper/server.py +255 -0
  9. py_opencode_wrapper-0.3.1/opencode_wrapper/session.py +258 -0
  10. py_opencode_wrapper-0.3.1/py_opencode_wrapper.egg-info/PKG-INFO +294 -0
  11. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/SOURCES.txt +6 -0
  12. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/pyproject.toml +1 -1
  13. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_client_async.py +50 -11
  14. py_opencode_wrapper-0.3.1/tests/test_integration_server_session.py +146 -0
  15. py_opencode_wrapper-0.3.1/tests/test_server.py +165 -0
  16. py_opencode_wrapper-0.3.1/tests/test_session.py +366 -0
  17. py_opencode_wrapper-0.2.2/PKG-INFO +0 -188
  18. py_opencode_wrapper-0.2.2/README.md +0 -175
  19. py_opencode_wrapper-0.2.2/py_opencode_wrapper.egg-info/PKG-INFO +0 -188
  20. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/opencode_wrapper/errors.py +0 -0
  21. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
  22. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/requires.txt +0 -0
  23. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
  24. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/setup.cfg +0 -0
  25. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_config_instructions.py +0 -0
  26. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_config_permission.py +0 -0
  27. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_event_parser.py +0 -0
  28. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_external_directory.py +0 -0
  29. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_instructions.py +0 -0
  30. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_multi_agent_weather.py +0 -0
  31. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_opencode.py +0 -0
  32. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_integration_parallel.py +0 -0
  33. {py_opencode_wrapper-0.2.2 → py_opencode_wrapper-0.3.1}/tests/test_run_result_fuzzy_text.py +0 -0
  34. {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 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",