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.
@@ -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