opencode-runtime 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_runtime/__init__.py +17 -0
- opencode_runtime/cli.py +299 -0
- opencode_runtime/client.py +228 -0
- opencode_runtime/event.py +26 -0
- opencode_runtime/exceptions.py +14 -0
- opencode_runtime/py.typed +0 -0
- opencode_runtime/registry.py +84 -0
- opencode_runtime/response.py +18 -0
- opencode_runtime/runtime.py +136 -0
- opencode_runtime/server.py +394 -0
- opencode_runtime/session.py +149 -0
- opencode_runtime-0.4.0.dist-info/METADATA +299 -0
- opencode_runtime-0.4.0.dist-info/RECORD +16 -0
- opencode_runtime-0.4.0.dist-info/WHEEL +4 -0
- opencode_runtime-0.4.0.dist-info/entry_points.txt +2 -0
- opencode_runtime-0.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .server import ServerManager, _compute_runtime_key
|
|
7
|
+
|
|
8
|
+
if t.TYPE_CHECKING:
|
|
9
|
+
from .session import OpenCodeSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenCodeRuntime:
|
|
13
|
+
"""Lifecycle manager and session factory for OpenCode Runtime.
|
|
14
|
+
|
|
15
|
+
``OpenCodeRuntime`` is the entry point for deploying OpenCode in a
|
|
16
|
+
multi-user backend. It manages isolated workspace environments,
|
|
17
|
+
OpenCode instance lifecycles, and session routing so that each user
|
|
18
|
+
gets a fully isolated runtime without any shared state.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
project_dir: The project directory OpenCode should run against.
|
|
22
|
+
runtime_dir: Where OpenCode Runtime stores managed workspace state.
|
|
23
|
+
When set, each session gets an isolated OpenCode instance
|
|
24
|
+
with its own HOME, config, and conversation history under
|
|
25
|
+
``runtime_dir/servers/<key>/``.
|
|
26
|
+
When not set, OpenCode runs with the user's real
|
|
27
|
+
environment and discovers config normally.
|
|
28
|
+
materials: OpenCode-native files to overlay into the runtime
|
|
29
|
+
workspace. Applied to every session unless overridden
|
|
30
|
+
per-session.
|
|
31
|
+
config: Raw OpenCode config dict. Merged with per-session
|
|
32
|
+
config (session config takes precedence).
|
|
33
|
+
env: Extra environment variables passed to the OpenCode
|
|
34
|
+
instance process.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
project_dir: str | Path = ".",
|
|
41
|
+
runtime_dir: str | Path | None = None,
|
|
42
|
+
materials: str | Path | list[str | Path] | None = None,
|
|
43
|
+
config: dict[str, t.Any] | None = None,
|
|
44
|
+
env: dict[str, str] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.project_dir = Path(project_dir).resolve()
|
|
47
|
+
self.runtime_dir = Path(runtime_dir).resolve() if runtime_dir else None
|
|
48
|
+
self.materials = materials
|
|
49
|
+
self.config = config or {}
|
|
50
|
+
self.env = env or {}
|
|
51
|
+
self._server_manager = ServerManager()
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Lifecycle
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> OpenCodeRuntime:
|
|
58
|
+
await self.start()
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
async def __aexit__(self, exc_type: t.Any, exc: t.Any, tb: t.Any) -> None:
|
|
62
|
+
await self.stop()
|
|
63
|
+
|
|
64
|
+
async def start(self) -> None:
|
|
65
|
+
"""Start the default OpenCode instance eagerly so the runtime is ready to use."""
|
|
66
|
+
await self.session()
|
|
67
|
+
|
|
68
|
+
async def stop(self) -> None:
|
|
69
|
+
"""Shut down all managed OpenCode instance processes."""
|
|
70
|
+
await self._server_manager.stop_all()
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Session factory
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async def session(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
workspace: str | None = None,
|
|
80
|
+
user_id: str | None = None,
|
|
81
|
+
session_id: str | None = None,
|
|
82
|
+
materials: str | Path | list[str | Path] | None = None,
|
|
83
|
+
config: dict[str, t.Any] | None = None,
|
|
84
|
+
env: dict[str, str] | None = None,
|
|
85
|
+
) -> OpenCodeSession:
|
|
86
|
+
"""Create a session backed by this runtime.
|
|
87
|
+
|
|
88
|
+
Each unique combination of workspace, user_id, materials, and config
|
|
89
|
+
maps to a dedicated OpenCode instance process. The instance is started
|
|
90
|
+
on first use and reused for subsequent sessions with the same key.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
workspace: Logical tenant/workspace name, e.g. ``"acme"``.
|
|
94
|
+
user_id: Application user id, e.g. ``"u_123"``.
|
|
95
|
+
session_id: OpenCode server-side session ID. Pass an existing ID to
|
|
96
|
+
resume a previous conversation; omit to start a new one.
|
|
97
|
+
materials: Per-session materials override. Falls back to
|
|
98
|
+
runtime-level materials when not set.
|
|
99
|
+
config: Merged on top of runtime-level config.
|
|
100
|
+
env: Merged on top of runtime-level env.
|
|
101
|
+
"""
|
|
102
|
+
from .session import OpenCodeSession
|
|
103
|
+
|
|
104
|
+
effective_materials = materials if materials is not None else self.materials
|
|
105
|
+
effective_config = {**self.config, **(config or {})}
|
|
106
|
+
effective_env = {**self.env, **(env or {})}
|
|
107
|
+
|
|
108
|
+
key = _compute_runtime_key(
|
|
109
|
+
workspace,
|
|
110
|
+
user_id,
|
|
111
|
+
self.project_dir,
|
|
112
|
+
effective_materials,
|
|
113
|
+
effective_config,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
server_dir = self.runtime_dir / "servers" / key if self.runtime_dir is not None else None
|
|
117
|
+
|
|
118
|
+
server = await self._server_manager.get_or_start(
|
|
119
|
+
key=key,
|
|
120
|
+
project_dir=self.project_dir,
|
|
121
|
+
server_dir=server_dir,
|
|
122
|
+
materials=effective_materials,
|
|
123
|
+
config=effective_config,
|
|
124
|
+
env=effective_env,
|
|
125
|
+
workspace=workspace,
|
|
126
|
+
user_id=user_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return OpenCodeSession(
|
|
130
|
+
client=server.client,
|
|
131
|
+
workspace=workspace,
|
|
132
|
+
user_id=user_id,
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
config=effective_config,
|
|
135
|
+
env=effective_env,
|
|
136
|
+
)
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal server lifecycle helpers.
|
|
3
|
+
|
|
4
|
+
All symbols in this module are private to opencode-runtime.
|
|
5
|
+
Nothing here is exported in __all__.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .client import OpenCodeClient
|
|
23
|
+
|
|
24
|
+
from .registry import RegistryEntry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _ManagedServer:
|
|
29
|
+
"""A running opencode server process tracked by the runtime."""
|
|
30
|
+
|
|
31
|
+
key: str
|
|
32
|
+
process: asyncio.subprocess.Process | None # set during _start(); None from get_or_start()
|
|
33
|
+
client: OpenCodeClient
|
|
34
|
+
server_dir: Path | None # None when runtime_dir is not set (no isolation)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_free_port(host: str = "127.0.0.1") -> int:
|
|
38
|
+
"""Bind to port 0 and let the OS pick a free ephemeral port."""
|
|
39
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
40
|
+
s.bind((host, 0))
|
|
41
|
+
return s.getsockname()[1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _wait_healthy(
|
|
45
|
+
client: OpenCodeClient,
|
|
46
|
+
timeout: float = 60.0,
|
|
47
|
+
process: asyncio.subprocess.Process | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Poll GET /global/health until the server responds or timeout expires."""
|
|
50
|
+
from .exceptions import OpenCodeTimeoutError
|
|
51
|
+
|
|
52
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
53
|
+
last_exc: Exception | None = None
|
|
54
|
+
|
|
55
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
56
|
+
if process is not None and process.returncode is not None:
|
|
57
|
+
raise OpenCodeTimeoutError(f"opencode process exited with code {process.returncode}")
|
|
58
|
+
try:
|
|
59
|
+
await client.health()
|
|
60
|
+
return
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
last_exc = exc
|
|
63
|
+
await asyncio.sleep(1.0)
|
|
64
|
+
|
|
65
|
+
raise OpenCodeTimeoutError(
|
|
66
|
+
f"opencode server did not become healthy within {timeout}s (last error: {last_exc})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _prepare_dir(
|
|
71
|
+
server_dir: Path,
|
|
72
|
+
config: dict[str, Any],
|
|
73
|
+
materials: str | Path | list[str | Path] | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Write opencode.json and overlay materials into server_dir."""
|
|
76
|
+
from .exceptions import OpenCodeRuntimeError
|
|
77
|
+
|
|
78
|
+
if config:
|
|
79
|
+
(server_dir / "opencode.json").write_text(
|
|
80
|
+
json.dumps(config, indent=2),
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if materials is not None:
|
|
85
|
+
paths = materials if isinstance(materials, list) else [materials]
|
|
86
|
+
for src in paths:
|
|
87
|
+
src = Path(src).resolve()
|
|
88
|
+
if not src.exists():
|
|
89
|
+
raise OpenCodeRuntimeError(f"materials path does not exist: {src}")
|
|
90
|
+
if src.is_dir():
|
|
91
|
+
for item in src.iterdir():
|
|
92
|
+
dest = server_dir / item.name
|
|
93
|
+
if item.is_dir():
|
|
94
|
+
shutil.copytree(item, dest, dirs_exist_ok=True)
|
|
95
|
+
else:
|
|
96
|
+
shutil.copy2(item, dest)
|
|
97
|
+
else:
|
|
98
|
+
shutil.copy2(src, server_dir / src.name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _terminate_process(process: asyncio.subprocess.Process) -> None:
|
|
102
|
+
"""Terminate a process gracefully, kill if it doesn't exit within 5s."""
|
|
103
|
+
if process.returncode is not None:
|
|
104
|
+
return # already exited
|
|
105
|
+
try:
|
|
106
|
+
process.terminate()
|
|
107
|
+
except ProcessLookupError:
|
|
108
|
+
return # already dead
|
|
109
|
+
try:
|
|
110
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
111
|
+
except asyncio.TimeoutError:
|
|
112
|
+
try:
|
|
113
|
+
process.kill()
|
|
114
|
+
except ProcessLookupError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _compute_runtime_key(
|
|
119
|
+
workspace: str | None,
|
|
120
|
+
user_id: str | None,
|
|
121
|
+
project_dir: Path,
|
|
122
|
+
materials: str | Path | list[str | Path] | None,
|
|
123
|
+
config: dict[str, Any],
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Compute a stable 16-char key for a unique server configuration.
|
|
126
|
+
|
|
127
|
+
Same inputs always produce the same key. Different inputs (different
|
|
128
|
+
workspace, user, materials, or config) produce different keys and
|
|
129
|
+
therefore get separate server processes.
|
|
130
|
+
"""
|
|
131
|
+
payload = "|".join(
|
|
132
|
+
[
|
|
133
|
+
workspace or "",
|
|
134
|
+
user_id or "",
|
|
135
|
+
str(project_dir),
|
|
136
|
+
repr(
|
|
137
|
+
sorted(
|
|
138
|
+
str(m)
|
|
139
|
+
for m in (materials if isinstance(materials, list) else [materials or ""])
|
|
140
|
+
)
|
|
141
|
+
),
|
|
142
|
+
json.dumps(config, sort_keys=True, default=str),
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ServerManager:
|
|
149
|
+
"""Manages a pool of OpenCode instance processes.
|
|
150
|
+
|
|
151
|
+
Each unique combination of workspace, user_id, project_dir, materials,
|
|
152
|
+
and config gets its own isolated OpenCode instance. Instances are started
|
|
153
|
+
on demand and reused when the same key is requested again.
|
|
154
|
+
|
|
155
|
+
The registry is the single source of truth. There is no in-memory cache —
|
|
156
|
+
every call consults the registry so that external actors (CLI stop-all,
|
|
157
|
+
another process) are always reflected immediately.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def find(self, key: str) -> RegistryEntry | None:
|
|
161
|
+
"""Return the registry entry for key, or None if not found."""
|
|
162
|
+
from .registry import read as registry_read
|
|
163
|
+
|
|
164
|
+
return registry_read(key)
|
|
165
|
+
|
|
166
|
+
def is_alive(self, key: str) -> bool:
|
|
167
|
+
"""Return True if the server for key is running."""
|
|
168
|
+
from .registry import is_alive
|
|
169
|
+
from .registry import read as registry_read
|
|
170
|
+
|
|
171
|
+
entry = registry_read(key)
|
|
172
|
+
return entry is not None and is_alive(entry.pid)
|
|
173
|
+
|
|
174
|
+
def list(self) -> list[tuple[RegistryEntry, bool]]:
|
|
175
|
+
"""Return all registry entries with their liveness status."""
|
|
176
|
+
from .registry import is_alive
|
|
177
|
+
from .registry import list_all
|
|
178
|
+
|
|
179
|
+
return [(entry, is_alive(entry.pid)) for entry in list_all()]
|
|
180
|
+
|
|
181
|
+
async def health(self, key: str) -> dict[str, Any]:
|
|
182
|
+
"""Return health info for the server at key.
|
|
183
|
+
|
|
184
|
+
Raises ``OpenCodeServerError`` if key is not in the registry or the
|
|
185
|
+
server is unreachable.
|
|
186
|
+
"""
|
|
187
|
+
import httpx
|
|
188
|
+
|
|
189
|
+
from .client import OpenCodeClient
|
|
190
|
+
from .exceptions import OpenCodeServerError
|
|
191
|
+
from .registry import read as registry_read
|
|
192
|
+
|
|
193
|
+
entry = registry_read(key)
|
|
194
|
+
if entry is None:
|
|
195
|
+
raise OpenCodeServerError(f"server {key!r} not found in registry")
|
|
196
|
+
|
|
197
|
+
client = OpenCodeClient(
|
|
198
|
+
base_url=f"http://127.0.0.1:{entry.port}",
|
|
199
|
+
password=entry.password,
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
return await client.health()
|
|
203
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
204
|
+
raise OpenCodeServerError(f"server {key!r} unreachable: {exc}") from exc
|
|
205
|
+
|
|
206
|
+
async def stop(self, key: str) -> bool:
|
|
207
|
+
"""Kill the server for key and remove its registry entry.
|
|
208
|
+
|
|
209
|
+
Returns True if the process was alive and killed, False if it was
|
|
210
|
+
already dead or not found in the registry.
|
|
211
|
+
"""
|
|
212
|
+
from .registry import delete as registry_delete
|
|
213
|
+
from .registry import is_alive
|
|
214
|
+
from .registry import read as registry_read
|
|
215
|
+
|
|
216
|
+
entry = registry_read(key)
|
|
217
|
+
if entry is None:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
registry_delete(key)
|
|
221
|
+
|
|
222
|
+
if not is_alive(entry.pid):
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
import signal
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
os.kill(entry.pid, signal.SIGTERM)
|
|
229
|
+
except ProcessLookupError:
|
|
230
|
+
return False
|
|
231
|
+
# Wait for the process to exit (up to 5s, then SIGKILL).
|
|
232
|
+
deadline = asyncio.get_event_loop().time() + 5.0
|
|
233
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
234
|
+
if not is_alive(entry.pid):
|
|
235
|
+
return True
|
|
236
|
+
await asyncio.sleep(0.1)
|
|
237
|
+
try:
|
|
238
|
+
os.kill(entry.pid, signal.SIGKILL)
|
|
239
|
+
except ProcessLookupError:
|
|
240
|
+
pass
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
async def stop_all(self) -> None:
|
|
244
|
+
"""Kill all servers tracked in the registry."""
|
|
245
|
+
from .registry import list_all
|
|
246
|
+
|
|
247
|
+
for entry in list_all():
|
|
248
|
+
await self.stop(entry.key)
|
|
249
|
+
|
|
250
|
+
async def get_or_start(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
key: str,
|
|
254
|
+
project_dir: Path,
|
|
255
|
+
server_dir: Path | None,
|
|
256
|
+
materials: str | Path | list[str | Path] | None,
|
|
257
|
+
config: dict[str, Any],
|
|
258
|
+
env: dict[str, str],
|
|
259
|
+
workspace: str | None = None,
|
|
260
|
+
user_id: str | None = None,
|
|
261
|
+
) -> _ManagedServer:
|
|
262
|
+
"""Return a client for the running server, starting one if needed."""
|
|
263
|
+
from .client import OpenCodeClient
|
|
264
|
+
from .registry import delete as registry_delete
|
|
265
|
+
from .registry import is_alive
|
|
266
|
+
from .registry import read as registry_read
|
|
267
|
+
|
|
268
|
+
entry = registry_read(key)
|
|
269
|
+
if entry is not None and is_alive(entry.pid):
|
|
270
|
+
return _ManagedServer(
|
|
271
|
+
key=key,
|
|
272
|
+
process=None,
|
|
273
|
+
client=OpenCodeClient(
|
|
274
|
+
base_url=f"http://127.0.0.1:{entry.port}",
|
|
275
|
+
password=entry.password,
|
|
276
|
+
),
|
|
277
|
+
server_dir=Path(entry.server_dir) if entry.server_dir else None,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Not running — clean up stale registry entry and start fresh.
|
|
281
|
+
if entry is not None:
|
|
282
|
+
registry_delete(key)
|
|
283
|
+
|
|
284
|
+
return await self._start(
|
|
285
|
+
key=key,
|
|
286
|
+
project_dir=project_dir,
|
|
287
|
+
server_dir=server_dir,
|
|
288
|
+
materials=materials,
|
|
289
|
+
config=config,
|
|
290
|
+
env=env,
|
|
291
|
+
workspace=workspace,
|
|
292
|
+
user_id=user_id,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def _start(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
key: str,
|
|
299
|
+
project_dir: Path,
|
|
300
|
+
server_dir: Path | None,
|
|
301
|
+
materials: str | Path | list[str | Path] | None,
|
|
302
|
+
config: dict[str, Any],
|
|
303
|
+
env: dict[str, str],
|
|
304
|
+
workspace: str | None = None,
|
|
305
|
+
user_id: str | None = None,
|
|
306
|
+
) -> _ManagedServer:
|
|
307
|
+
"""Start a new OpenCode instance and return a _ManagedServer."""
|
|
308
|
+
from .client import OpenCodeClient
|
|
309
|
+
from .exceptions import OpenCodeNotFoundError
|
|
310
|
+
|
|
311
|
+
if shutil.which("opencode") is None:
|
|
312
|
+
raise OpenCodeNotFoundError(
|
|
313
|
+
"opencode binary not found on PATH. Install it with: npm install -g opencode-ai"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if server_dir is not None:
|
|
317
|
+
server_dir.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
(server_dir / "tmp").mkdir(exist_ok=True)
|
|
319
|
+
_prepare_dir(server_dir, config, materials)
|
|
320
|
+
|
|
321
|
+
port = _find_free_port()
|
|
322
|
+
password = secrets.token_urlsafe(32)
|
|
323
|
+
|
|
324
|
+
process_env = {**os.environ, **env}
|
|
325
|
+
process_env["OPENCODE_SERVER_PASSWORD"] = password
|
|
326
|
+
|
|
327
|
+
if server_dir is not None:
|
|
328
|
+
process_env["HOME"] = str(server_dir)
|
|
329
|
+
process_env["TMPDIR"] = str(server_dir / "tmp")
|
|
330
|
+
process_env["OPENCODE_CONFIG_HOME"] = str(server_dir)
|
|
331
|
+
|
|
332
|
+
if server_dir is not None:
|
|
333
|
+
log_file = open(server_dir / "opencode.log", "ab")
|
|
334
|
+
stdout = log_file
|
|
335
|
+
stderr = log_file
|
|
336
|
+
else:
|
|
337
|
+
stdout = asyncio.subprocess.DEVNULL
|
|
338
|
+
stderr = asyncio.subprocess.DEVNULL
|
|
339
|
+
|
|
340
|
+
process = await asyncio.create_subprocess_exec(
|
|
341
|
+
"opencode",
|
|
342
|
+
"serve",
|
|
343
|
+
"--hostname",
|
|
344
|
+
"127.0.0.1",
|
|
345
|
+
"--port",
|
|
346
|
+
str(port),
|
|
347
|
+
cwd=str(project_dir),
|
|
348
|
+
env=process_env,
|
|
349
|
+
stdout=stdout,
|
|
350
|
+
stderr=stderr,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
client = OpenCodeClient(
|
|
354
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
355
|
+
password=password,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Use a short per-request timeout while polling — avoids a single
|
|
359
|
+
# hanging request consuming the entire startup budget.
|
|
360
|
+
poll_client = OpenCodeClient(
|
|
361
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
362
|
+
password=password,
|
|
363
|
+
timeout=3.0,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
await _wait_healthy(poll_client, process=process)
|
|
368
|
+
except Exception:
|
|
369
|
+
await _terminate_process(process)
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
from .registry import RegistryEntry, now_iso
|
|
373
|
+
from .registry import write as registry_write
|
|
374
|
+
|
|
375
|
+
registry_write(
|
|
376
|
+
RegistryEntry(
|
|
377
|
+
key=key,
|
|
378
|
+
pid=process.pid,
|
|
379
|
+
port=port,
|
|
380
|
+
password=password,
|
|
381
|
+
project_dir=str(project_dir),
|
|
382
|
+
server_dir=str(server_dir) if server_dir else None,
|
|
383
|
+
started_at=now_iso(),
|
|
384
|
+
workspace=workspace,
|
|
385
|
+
user_id=user_id,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return _ManagedServer(
|
|
390
|
+
key=key,
|
|
391
|
+
process=process,
|
|
392
|
+
client=client,
|
|
393
|
+
server_dir=server_dir,
|
|
394
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from .event import OpenCodeEvent
|
|
6
|
+
from .response import OpenCodeResponse
|
|
7
|
+
|
|
8
|
+
if t.TYPE_CHECKING:
|
|
9
|
+
from .client import OpenCodeClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenCodeSession:
|
|
13
|
+
"""A conversation session with an OpenCode server.
|
|
14
|
+
|
|
15
|
+
Sessions are self-contained — they hold their own client, config, and
|
|
16
|
+
conversation state. Obtain via ``OpenCodeRuntime.session()``.
|
|
17
|
+
|
|
18
|
+
Each session maps to a single OpenCode server-side session. Calling
|
|
19
|
+
``ask()`` or ``stream()`` multiple times on the same
|
|
20
|
+
``OpenCodeSession`` instance sends follow-up messages within the same
|
|
21
|
+
conversation thread — OpenCode maintains the full history and context
|
|
22
|
+
between turns.
|
|
23
|
+
|
|
24
|
+
To start a new independent conversation, obtain a new session via
|
|
25
|
+
``OpenCodeRuntime.session()``.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
client: The HTTP client for the server backing this session.
|
|
29
|
+
workspace: Logical tenant/workspace name, e.g. ``"acme"``.
|
|
30
|
+
user_id: Application user id, e.g. ``"u_123"``.
|
|
31
|
+
session_id: OpenCode server-side session ID. Pass an existing ID to
|
|
32
|
+
resume a previous conversation; omit to start a new one.
|
|
33
|
+
Readable after the first ``ask()`` / ``stream()`` call.
|
|
34
|
+
config: Merged OpenCode config dict for this session.
|
|
35
|
+
env: Environment variable overrides for this session.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
client: OpenCodeClient,
|
|
42
|
+
workspace: str | None,
|
|
43
|
+
user_id: str | None,
|
|
44
|
+
session_id: str | None,
|
|
45
|
+
config: dict[str, t.Any],
|
|
46
|
+
env: dict[str, str],
|
|
47
|
+
) -> None:
|
|
48
|
+
self._client = client
|
|
49
|
+
self.workspace = workspace
|
|
50
|
+
self.user_id = user_id
|
|
51
|
+
self.session_id = session_id # None until first stream(); set to resume
|
|
52
|
+
self.config = config
|
|
53
|
+
self.env = env
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def raw_client(self) -> OpenCodeClient:
|
|
57
|
+
"""The HTTP client for the server backing this session."""
|
|
58
|
+
return self._client
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# Public interface
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
async def ask(self, message: str, **kwargs: t.Any) -> OpenCodeResponse:
|
|
65
|
+
"""Send a message and collect the full response.
|
|
66
|
+
|
|
67
|
+
Accumulates incremental text from ``message.part.delta`` events and
|
|
68
|
+
returns a single :class:`OpenCodeResponse` when the session goes idle.
|
|
69
|
+
All events (tool calls, thinking, status updates, etc.) are preserved
|
|
70
|
+
in ``OpenCodeResponse.raw`` for inspection.
|
|
71
|
+
"""
|
|
72
|
+
from .exceptions import OpenCodeServerError
|
|
73
|
+
|
|
74
|
+
text = ""
|
|
75
|
+
raw_events: list[t.Any] = []
|
|
76
|
+
|
|
77
|
+
async for event in self.stream(message, **kwargs):
|
|
78
|
+
raw_events.append(event.raw)
|
|
79
|
+
if event.type == "session.error":
|
|
80
|
+
raise OpenCodeServerError(event.text or "unknown error from opencode server")
|
|
81
|
+
if event.type == "message.part.delta" and event.text:
|
|
82
|
+
text += event.text
|
|
83
|
+
|
|
84
|
+
return OpenCodeResponse(text=text, raw=raw_events)
|
|
85
|
+
|
|
86
|
+
async def stream(
|
|
87
|
+
self,
|
|
88
|
+
message: str,
|
|
89
|
+
*,
|
|
90
|
+
model: str | None = None,
|
|
91
|
+
agent: str | None = None,
|
|
92
|
+
tools: dict[str, bool] | None = None,
|
|
93
|
+
system: str | None = None,
|
|
94
|
+
**kwargs: t.Any,
|
|
95
|
+
) -> t.AsyncIterator[OpenCodeEvent]:
|
|
96
|
+
"""Send a message and stream all events as they arrive.
|
|
97
|
+
|
|
98
|
+
Yields every :class:`OpenCodeEvent` emitted by OpenCode for this
|
|
99
|
+
session — text deltas, tool calls, thinking, status updates,
|
|
100
|
+
permission requests, and terminal events. No filtering or
|
|
101
|
+
interpretation is applied; callers decide what to handle.
|
|
102
|
+
|
|
103
|
+
Calling ``stream()`` again on the same session after the first
|
|
104
|
+
stream completes sends a follow-up message in the same conversation
|
|
105
|
+
thread — full history is preserved server-side.
|
|
106
|
+
|
|
107
|
+
The stream terminates automatically on ``session.idle`` or
|
|
108
|
+
``session.error``.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
message: The user message to send.
|
|
112
|
+
model: Override the model, e.g. ``"anthropic/claude-sonnet-4-5"``.
|
|
113
|
+
agent: Override the agent.
|
|
114
|
+
tools: Per-tool enable/disable map, e.g. ``{"bash": False}``.
|
|
115
|
+
system: Additional system prompt text.
|
|
116
|
+
"""
|
|
117
|
+
# Create an OpenCode session server-side if we don't have one yet.
|
|
118
|
+
# If session_id was provided at construction, skip creation — resume
|
|
119
|
+
# the existing conversation.
|
|
120
|
+
if self.session_id is None:
|
|
121
|
+
result = await self._client.post("/session", {})
|
|
122
|
+
self.session_id = result["id"]
|
|
123
|
+
|
|
124
|
+
session_id = self.session_id
|
|
125
|
+
assert session_id is not None
|
|
126
|
+
|
|
127
|
+
# Send the prompt first — prompt_async returns immediately once the
|
|
128
|
+
# server has accepted the message. Starting the SSE stream before
|
|
129
|
+
# send() completes means we risk missing early events.
|
|
130
|
+
await self._client.send(
|
|
131
|
+
session_id,
|
|
132
|
+
message,
|
|
133
|
+
model=model,
|
|
134
|
+
agent=agent,
|
|
135
|
+
tools=tools,
|
|
136
|
+
system=system,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async for event in self._client.events(session_id):
|
|
140
|
+
yield event
|
|
141
|
+
|
|
142
|
+
async def abort(self) -> None:
|
|
143
|
+
"""Abort a running session."""
|
|
144
|
+
if self.session_id is not None:
|
|
145
|
+
await self._client.post(f"/session/{self.session_id}/abort", {})
|
|
146
|
+
|
|
147
|
+
async def close(self) -> None:
|
|
148
|
+
"""Release conversation-level resources."""
|
|
149
|
+
self.session_id = None
|