py-opencode-wrapper 0.3.0__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 (33) hide show
  1. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/PKG-INFO +85 -19
  2. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/README.md +84 -18
  3. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/opencode_wrapper/__init__.py +12 -2
  4. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/opencode_wrapper/client.py +23 -32
  5. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/opencode_wrapper/config.py +42 -28
  6. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/opencode_wrapper/events.py +118 -0
  7. py_opencode_wrapper-0.3.1/opencode_wrapper/server.py +255 -0
  8. py_opencode_wrapper-0.3.1/opencode_wrapper/session.py +258 -0
  9. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/PKG-INFO +85 -19
  10. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/SOURCES.txt +3 -0
  11. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/pyproject.toml +1 -1
  12. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_client_async.py +50 -11
  13. py_opencode_wrapper-0.3.1/tests/test_integration_server_session.py +146 -0
  14. py_opencode_wrapper-0.3.1/tests/test_server.py +165 -0
  15. py_opencode_wrapper-0.3.1/tests/test_session.py +366 -0
  16. py_opencode_wrapper-0.3.0/opencode_wrapper/session.py +0 -90
  17. py_opencode_wrapper-0.3.0/tests/test_session.py +0 -108
  18. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/LICENSE +0 -0
  19. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/opencode_wrapper/errors.py +0 -0
  20. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
  21. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/requires.txt +0 -0
  22. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
  23. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/setup.cfg +0 -0
  24. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_config_instructions.py +0 -0
  25. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_config_permission.py +0 -0
  26. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_event_parser.py +0 -0
  27. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_integration_external_directory.py +0 -0
  28. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_integration_instructions.py +0 -0
  29. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_integration_multi_agent_weather.py +0 -0
  30. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_integration_opencode.py +0 -0
  31. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_integration_parallel.py +0 -0
  32. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_run_result_fuzzy_text.py +0 -0
  33. {py_opencode_wrapper-0.3.0 → py_opencode_wrapper-0.3.1}/tests/test_user_config_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-opencode-wrapper
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -77,17 +77,21 @@ async def main():
77
77
  asyncio.run(main())
78
78
  ```
79
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.
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.
85
87
 
86
88
  ### Multi-turn conversation (`OpenCodeSession`)
87
89
 
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:
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:
91
95
 
92
96
  ```python
93
97
  import asyncio
@@ -97,19 +101,65 @@ async def chat():
97
101
  client = AsyncOpenCodeClient()
98
102
  async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
99
103
  r1 = await s.send("My name is Bob.")
100
- r2 = await s.send("What is my name?") # auto-continues → "Bob"
104
+ r2 = await s.send("What is my name?") # continues natively → "Bob"
101
105
  print(s.session_id, r2.final_text)
102
106
 
103
107
  asyncio.run(chat())
104
108
  ```
105
109
 
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.
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.
113
163
 
114
164
  ### Stream structured JSON events
115
165
 
@@ -175,7 +225,23 @@ host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
175
225
 
176
226
  ## CLI arguments
177
227
 
178
- `RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
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).
179
245
 
180
246
  ## Tests
181
247
 
@@ -220,7 +286,7 @@ The defaults already handle the common pitfalls when running many `async_run` ca
220
286
 
221
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.
222
288
 
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.
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.
224
290
 
225
291
  ## Notes
226
292
 
@@ -62,17 +62,21 @@ async def main():
62
62
  asyncio.run(main())
63
63
  ```
64
64
 
65
- Set `RunConfig(record_thinking=True)` when you want OpenCode reasoning/thinking
66
- parts included in `result.events` and `log_file` JSON lines. This only maps to
67
- OpenCode's display/output flag `--thinking`; it does not change model reasoning
68
- effort. Use `variant` separately if you intentionally want a provider-specific
69
- reasoning effort.
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.
70
72
 
71
73
  ### Multi-turn conversation (`OpenCodeSession`)
72
74
 
73
- For a stateful, multi-turn chat over a single opencode session, use
74
- `OpenCodeSession` as an async context manager. Each `send()` continues the same
75
- session, so the model retains context across turns:
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:
76
80
 
77
81
  ```python
78
82
  import asyncio
@@ -82,19 +86,65 @@ async def chat():
82
86
  client = AsyncOpenCodeClient()
83
87
  async with OpenCodeSession(client, ".", run_cfg=RunConfig(model="opencode/big-pickle")) as s:
84
88
  r1 = await s.send("My name is Bob.")
85
- r2 = await s.send("What is my name?") # auto-continues → "Bob"
89
+ r2 = await s.send("What is my name?") # continues natively → "Bob"
86
90
  print(s.session_id, r2.final_text)
87
91
 
88
92
  asyncio.run(chat())
89
93
  ```
90
94
 
91
- On enter, the session allocates a private, persistent `XDG_DATA_HOME` tmpdir that
92
- every turn reuses, so opencode's SQLite session DB survives across turns. The
93
- first `send()` creates the session (its id is captured on `RunResult.session_id`);
94
- later turns continue it via `--session <id>`. Each session is an isolated island —
95
- no shared global DB, so no cross-session lock contention and the tmpdir is
96
- removed when the `async with` block exits. `send()` accepts per-turn `run_cfg` and
97
- `timeout_s` overrides.
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.
98
148
 
99
149
  ### Stream structured JSON events
100
150
 
@@ -160,7 +210,23 @@ host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
160
210
 
161
211
  ## CLI arguments
162
212
 
163
- `RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
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).
164
230
 
165
231
  ## Tests
166
232
 
@@ -205,7 +271,7 @@ The defaults already handle the common pitfalls when running many `async_run` ca
205
271
 
206
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.
207
273
 
208
- > 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.
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.
209
275
 
210
276
  ## Notes
211
277
 
@@ -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,23 +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
  )
19
- from opencode_wrapper.session import OpenCodeSession
25
+ from opencode_wrapper.session import OpenCodeSession, PermissionCallback, QuestionCallback
20
26
 
21
27
  __all__ = [
22
28
  "AsyncOpenCodeClient",
23
29
  "OpenCodeSession",
30
+ "PermissionCallback",
31
+ "QuestionCallback",
24
32
  "RunConfig",
25
33
  "RunResult",
26
34
  "TokenUsage",
27
35
  "aggregate_run_result",
36
+ "aggregate_server_result",
28
37
  "build_argv",
29
38
  "build_env",
30
39
  "parse_event_line",
31
40
  "run_result_fuzzy_text",
32
41
  "resolve_binary",
42
+ "split_model",
33
43
  "validate_config_for_run",
34
44
  "validate_permission_actions",
35
45
  "OpenCodeError",
@@ -38,43 +38,34 @@ def build_argv(
38
38
  prompt: str,
39
39
  run_cfg: RunConfig,
40
40
  ) -> list[str]:
41
- """Build ``opencode run`` argument list."""
41
+ """Build ``opencode run`` argument list.
42
+
43
+ ``model`` / ``agent`` are structured fields shared with server mode and map
44
+ to ``-m`` / ``--agent``. Every other ``opencode run`` flag is passed through
45
+ ``run_cfg.cli_kwargs``: each entry expands to ``--flag`` (bool ``True``),
46
+ ``--flag=value`` (multi-char key) / ``-f value`` (single-char key), or a
47
+ repetition per element (list/tuple value). ``False`` / ``None`` are skipped.
48
+ The argv list is handed to ``create_subprocess_exec`` (no shell), so values
49
+ are not subject to shell injection.
50
+ """
42
51
  cmd: list[str] = [binary_resolved, "run", "--format", "json"]
43
-
44
- if run_cfg.print_logs:
45
- cmd.append("--print-logs")
46
- if run_cfg.log_level:
47
- cmd.extend(["--log-level", run_cfg.log_level])
48
- if run_cfg.command:
49
- cmd.extend(["--command", run_cfg.command])
50
- if run_cfg.continue_session:
51
- cmd.append("--continue")
52
- if run_cfg.session_id:
53
- cmd.extend(["--session", run_cfg.session_id])
54
- if run_cfg.fork:
55
- cmd.append("--fork")
56
- if run_cfg.share is True:
57
- cmd.append("--share")
58
52
  if run_cfg.model:
59
53
  cmd.extend(["-m", run_cfg.model])
60
54
  if run_cfg.agent:
61
55
  cmd.extend(["--agent", run_cfg.agent])
62
- for f in run_cfg.files:
63
- cmd.extend(["-f", str(f)])
64
- if run_cfg.title:
65
- cmd.extend(["--title", run_cfg.title])
66
- if run_cfg.attach:
67
- cmd.extend(["--attach", run_cfg.attach])
68
- if run_cfg.password:
69
- cmd.extend(["-p", run_cfg.password])
70
- if run_cfg.remote_dir:
71
- cmd.extend(["--dir", run_cfg.remote_dir])
72
- if run_cfg.port is not None:
73
- cmd.extend(["--port", str(run_cfg.port)])
74
- if run_cfg.variant:
75
- cmd.extend(["--variant", run_cfg.variant])
76
- if run_cfg.record_thinking is True or run_cfg.thinking is True:
77
- cmd.append("--thinking")
56
+
57
+ for key, val in (run_cfg.cli_kwargs or {}).items():
58
+ flag = f"-{key}" if len(key) == 1 else f"--{key}"
59
+ vals = val if isinstance(val, (list, tuple)) else [val]
60
+ for v in vals:
61
+ if v is True:
62
+ cmd.append(flag)
63
+ elif v is False or v is None:
64
+ continue
65
+ elif len(key) == 1:
66
+ cmd.extend([flag, str(v)])
67
+ else:
68
+ cmd.append(f"{flag}={v}")
78
69
 
79
70
  if prompt:
80
71
  cmd.append(prompt)
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from dataclasses import dataclass
7
- from pathlib import Path
8
7
  from typing import Any, Dict, Mapping
9
8
 
10
9
  # Permission values accepted by OpenCode
@@ -131,29 +130,17 @@ def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str,
131
130
 
132
131
  @dataclass
133
132
  class RunConfig:
134
- """Per-invocation settings merged into env and CLI."""
133
+ """Per-invocation settings merged into env and CLI.
135
134
 
135
+ Most fields are honored by both modes (run mode via CLI/env, server/session
136
+ mode via the prompt body/env). ``cli_kwargs`` is the exception: it is a
137
+ raw passthrough of ``opencode run`` CLI flags and is **ignored by server/
138
+ session mode**, which has no CLI surface.
139
+ """
140
+
141
+ # --- honored by both modes ---
136
142
  agent: str | None = None
137
143
  model: str | None = None
138
- files: tuple[str | Path, ...] = ()
139
- title: str | None = None
140
- command: str | None = None
141
- continue_session: bool = False
142
- session_id: str | None = None
143
- fork: bool = False
144
- share: bool | None = None
145
- attach: str | None = None
146
- password: str | None = None
147
- remote_dir: str | None = None
148
- port: int | None = None
149
- variant: str | None = None
150
- # Include OpenCode reasoning/thinking parts in the JSON event stream.
151
- # This maps to `opencode run --thinking`; it does not set model reasoning effort.
152
- record_thinking: bool | None = None
153
- # Backward-compatible alias for record_thinking.
154
- thinking: bool | None = None
155
- print_logs: bool | None = None
156
- log_level: str | None = None
157
144
  disable_autoupdate: bool = True
158
145
  inherit_user_config: bool = False
159
146
  extra_env: Mapping[str, str] | None = None
@@ -163,6 +150,16 @@ class RunConfig:
163
150
  tools: dict[str, Any] | None = None
164
151
  instructions: list[str] | None = None
165
152
  config_overrides: dict[str, Any] | None = None
153
+ # --- run mode only: passed through verbatim to `opencode run` as CLI flags;
154
+ # server/session mode ignores this entirely.
155
+ # e.g. {"title": "x", "continue": True, "session": "ses_1", "fork": True,
156
+ # "thinking": True, "f": ["a.txt"]}
157
+ # build_argv expands each entry: bool True -> "--flag", a value -> "--flag=value"
158
+ # (long) or "-f value" (single-char), a list -> repeated, False/None -> skipped.
159
+ # Note: server mode has no --thinking equivalent; reasoning parts are produced
160
+ # per the model's reasoning config and published to the SSE bus unconditionally,
161
+ # so they already land in result.events / log_file with no opt-in.
162
+ cli_kwargs: dict[str, Any] | None = None
166
163
 
167
164
  def build_opencode_config_dict(self) -> dict[str, Any]:
168
165
  """Build the dict serialized to ``OPENCODE_CONFIG_CONTENT``."""
@@ -186,15 +183,32 @@ class RunConfig:
186
183
  return json.dumps(cfg, ensure_ascii=False)
187
184
 
188
185
 
189
- def validate_permission_actions(obj: Any, *, _path: str = "") -> None:
190
- """Ensure string leaves are non-interactive OpenCode permission actions.
186
+ def split_model(model: str) -> dict[str, str]:
187
+ """Split a ``"providerID/modelID"`` string into the server prompt-body shape.
188
+
189
+ opencode's server API wants ``{"providerID": ..., "modelID": ...}`` whereas
190
+ ``opencode run -m`` takes the ``provider/model`` string. Only the first ``/``
191
+ separates provider from model (model ids may themselves contain ``/``). A
192
+ string with no ``/`` is treated as a bare model id with an empty provider,
193
+ letting the server fall back to its default provider resolution.
194
+ """
195
+ provider, sep, rest = model.partition("/")
196
+ if not sep:
197
+ return {"providerID": "", "modelID": model}
198
+ return {"providerID": provider, "modelID": rest}
199
+
200
+
201
+ def validate_permission_actions(obj: Any, *, _path: str = "", allow_ask: bool = False) -> None:
202
+ """Ensure string leaves are valid OpenCode permission actions.
191
203
 
192
- ``"ask"`` is rejected because the subprocess has no terminal to prompt —
193
- it would block forever.
204
+ ``"ask"`` is rejected by default because the run-mode subprocess has no
205
+ terminal to prompt — it would block forever. The server/session path passes
206
+ ``allow_ask=True`` because there a ``permission.asked`` event is answerable via
207
+ the ``on_permission`` callback.
194
208
  """
195
- allowed = frozenset({"allow", "deny"})
209
+ allowed = frozenset({"allow", "deny", "ask"} if allow_ask else {"allow", "deny"})
196
210
  if isinstance(obj, str):
197
- if obj == "ask":
211
+ if obj == "ask" and not allow_ask:
198
212
  loc = f" at {_path!r}" if _path else ""
199
213
  raise ValueError(
200
214
  f"Permission action 'ask' is not supported in non-interactive "
@@ -209,7 +223,7 @@ def validate_permission_actions(obj: Any, *, _path: str = "") -> None:
209
223
  if isinstance(obj, dict):
210
224
  for k, v in obj.items():
211
225
  child_path = f"{_path}.{k}" if _path else k
212
- validate_permission_actions(v, _path=child_path)
226
+ validate_permission_actions(v, _path=child_path, allow_ask=allow_ask)
213
227
 
214
228
 
215
229
  def validate_config_for_run(cfg: RunConfig) -> None:
@@ -203,3 +203,121 @@ def aggregate_run_result(
203
203
  for ev in events:
204
204
  r.append_event(ev)
205
205
  return r
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Server-mode (opencode serve / SSE) aggregation
210
+ #
211
+ # Server event shapes differ from `opencode run --format json`:
212
+ # {"type": "message.part.updated",
213
+ # "properties": {"sessionID": "...", "part": {"type": "text"|"tool"|"reasoning",
214
+ # "text": "...", "id": "prt_...", ...}}}
215
+ # {"type": "message.updated", "properties": {"info": {"role": "assistant",
216
+ # "tokens": {...}, "cost": ...}}}
217
+ # Run-mode parsing above is intentionally left untouched.
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def _server_assistant_text_from_messages(messages: list[dict[str, Any]]) -> str:
222
+ """Extract the last assistant message's concatenated text parts.
223
+
224
+ ``GET /session/{id}/message`` returns ``[{info:{role,...}, parts:[...]}, ...]``.
225
+ The authoritative final answer is the text parts of the final assistant turn.
226
+ """
227
+ last = ""
228
+ for m in messages:
229
+ info = m.get("info") if isinstance(m, dict) else None
230
+ if not isinstance(info, dict) or info.get("role") != "assistant":
231
+ continue
232
+ parts = m.get("parts")
233
+ if not isinstance(parts, list):
234
+ continue
235
+ texts = [
236
+ p["text"]
237
+ for p in parts
238
+ if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str)
239
+ ]
240
+ joined = "".join(texts).strip()
241
+ if joined:
242
+ last = joined
243
+ return last
244
+
245
+
246
+ def _accumulate_token_usage(usage: TokenUsage, tokens: Any, cost_acc: list[float], info: dict) -> None:
247
+ cost = info.get("cost")
248
+ if isinstance(cost, (int, float)):
249
+ cost_acc[0] += float(cost)
250
+ if isinstance(tokens, dict):
251
+ for attr, key in (("total", "total"), ("input", "input"), ("output", "output"), ("reasoning", "reasoning")):
252
+ val = tokens.get(key)
253
+ if isinstance(val, (int, float)):
254
+ setattr(usage, attr, getattr(usage, attr) + int(val))
255
+ cache = tokens.get("cache")
256
+ if isinstance(cache, dict):
257
+ for attr, key in (("cache_read", "read"), ("cache_write", "write")):
258
+ val = cache.get(key)
259
+ if isinstance(val, (int, float)):
260
+ setattr(usage, attr, getattr(usage, attr) + int(val))
261
+
262
+
263
+ def aggregate_server_result(
264
+ *,
265
+ events: list[dict[str, Any]],
266
+ session_id: str | None,
267
+ final_messages: list[dict[str, Any]] | None = None,
268
+ exit_code: int | None = 0,
269
+ stderr: str = "",
270
+ ) -> RunResult:
271
+ """Build a :class:`RunResult` from a server-mode turn's SSE events.
272
+
273
+ ``events`` are the raw SSE event dicts collected during the turn. When
274
+ ``final_messages`` (the ``GET /session/{id}/message`` payload) is supplied it
275
+ is treated as the authoritative source for final text and token/cost totals;
276
+ otherwise those are reconstructed from the streamed events.
277
+ """
278
+ r = RunResult(events=list(events), exit_code=exit_code, stderr=stderr, session_id=session_id)
279
+
280
+ # tool_calls + streamed text snapshots keyed by part id (parts are replaced,
281
+ # not appended, as they stream — keep the latest snapshot per id).
282
+ text_by_part: dict[str, str] = {}
283
+ text_order: list[str] = []
284
+ cost_acc = [0.0]
285
+ seen_assistant_msgs: set[str] = set()
286
+
287
+ for ev in events:
288
+ etype = ev.get("type")
289
+ props = ev.get("properties", {}) if isinstance(ev.get("properties"), dict) else {}
290
+ if etype in ("message.part.updated", "message.part.delta"):
291
+ part = props.get("part")
292
+ if not isinstance(part, dict):
293
+ continue
294
+ ptype = part.get("type")
295
+ pid = part.get("id") or ""
296
+ if ptype == "text" and isinstance(part.get("text"), str):
297
+ if pid not in text_by_part:
298
+ text_order.append(pid)
299
+ text_by_part[pid] = part["text"]
300
+ elif ptype == "tool":
301
+ r.tool_calls.append({
302
+ "type": "tool",
303
+ "tool": part.get("tool"),
304
+ "callID": part.get("callID"),
305
+ "state": part.get("state"),
306
+ "id": pid,
307
+ })
308
+ elif etype == "message.updated":
309
+ info = props.get("info")
310
+ if isinstance(info, dict) and info.get("role") == "assistant":
311
+ mid = info.get("id") or ""
312
+ if mid not in seen_assistant_msgs:
313
+ seen_assistant_msgs.add(mid)
314
+ r.turns += 1
315
+ _accumulate_token_usage(r.token_usage, info.get("tokens"), cost_acc, info)
316
+
317
+ r.total_cost = cost_acc[0]
318
+
319
+ if final_messages is not None:
320
+ r.final_text = _server_assistant_text_from_messages(final_messages)
321
+ if not r.final_text:
322
+ r.final_text = "".join(text_by_part[pid] for pid in text_order).strip()
323
+ return r