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,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.4.0"
|
|
6
|
+
|
|
7
|
+
from .event import OpenCodeEvent
|
|
8
|
+
from .runtime import OpenCodeRuntime
|
|
9
|
+
from .response import OpenCodeResponse
|
|
10
|
+
from .session import OpenCodeSession
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"OpenCodeRuntime",
|
|
14
|
+
"OpenCodeSession",
|
|
15
|
+
"OpenCodeEvent",
|
|
16
|
+
"OpenCodeResponse",
|
|
17
|
+
]
|
opencode_runtime/cli.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for opencode-runtime instance management.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
serve Start an OpenCode instance (detached)
|
|
6
|
+
ps List all instances tracked in the registry
|
|
7
|
+
stop Stop an instance by key
|
|
8
|
+
stop-all Stop all tracked instances
|
|
9
|
+
health Check health of an instance by key
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .server import ServerManager, _compute_runtime_key
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# ANSI
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_R = "\033[0m"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _green(s: str) -> str:
|
|
30
|
+
return f"\033[32m{s}{_R}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _yellow(s: str) -> str:
|
|
34
|
+
return f"\033[33m{s}{_R}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _red(s: str) -> str:
|
|
38
|
+
return f"\033[31m{s}{_R}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cyan(s: str) -> str:
|
|
42
|
+
return f"\033[36m{s}{_R}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dim(s: str) -> str:
|
|
46
|
+
return f"\033[2m{s}{_R}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# helpers
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _home(path: str) -> str:
|
|
55
|
+
try:
|
|
56
|
+
return "~/" + str(Path(path).relative_to(Path.home()))
|
|
57
|
+
except ValueError:
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _uptime(started_at: str, alive: bool) -> str:
|
|
62
|
+
try:
|
|
63
|
+
mins = max(
|
|
64
|
+
0,
|
|
65
|
+
int(
|
|
66
|
+
(datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds()
|
|
67
|
+
// 60
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
return "?"
|
|
72
|
+
return f"Up {mins}m" if alive else f"Dead {mins}m"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _row(label: str, value: str) -> None:
|
|
76
|
+
print(f" {_cyan(f'{label:<9}')} {value}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# serve
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _serve(args: argparse.Namespace) -> None:
|
|
85
|
+
project_dir = Path(args.project_dir).resolve()
|
|
86
|
+
runtime_dir = Path(args.runtime_dir).resolve() if args.runtime_dir else None
|
|
87
|
+
materials = args.materials or None
|
|
88
|
+
|
|
89
|
+
key = _compute_runtime_key(
|
|
90
|
+
workspace=args.workspace,
|
|
91
|
+
user_id=args.user_id,
|
|
92
|
+
project_dir=project_dir,
|
|
93
|
+
materials=materials,
|
|
94
|
+
config={},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
manager = ServerManager()
|
|
98
|
+
|
|
99
|
+
existing = manager.find(key)
|
|
100
|
+
if existing is not None:
|
|
101
|
+
if manager.is_alive(key):
|
|
102
|
+
sys.exit(
|
|
103
|
+
_yellow(f"● Server already running id={existing.key} pid={existing.pid}\n")
|
|
104
|
+
+ _dim(f" use: opencode-runtime stop {existing.key}")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
server_dir: Path | None = None
|
|
108
|
+
if runtime_dir is not None:
|
|
109
|
+
server_dir = runtime_dir / "servers" / key
|
|
110
|
+
|
|
111
|
+
print(_yellow("● Starting opencode server..."), flush=True)
|
|
112
|
+
|
|
113
|
+
server = await manager.get_or_start(
|
|
114
|
+
key=key,
|
|
115
|
+
project_dir=project_dir,
|
|
116
|
+
server_dir=server_dir,
|
|
117
|
+
materials=materials,
|
|
118
|
+
config={},
|
|
119
|
+
env={},
|
|
120
|
+
workspace=args.workspace,
|
|
121
|
+
user_id=args.user_id,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
entry = manager.find(key)
|
|
125
|
+
assert entry is not None
|
|
126
|
+
|
|
127
|
+
print(f"\r{_green('✓ Server started')}\n")
|
|
128
|
+
_row("ID", key)
|
|
129
|
+
if args.workspace:
|
|
130
|
+
_row("Workspace", args.workspace)
|
|
131
|
+
if args.user_id:
|
|
132
|
+
_row("User", args.user_id)
|
|
133
|
+
_row("Status", _green("● alive"))
|
|
134
|
+
_row("URL", server.client.base_url)
|
|
135
|
+
_row("PID", _dim(str(entry.pid)))
|
|
136
|
+
_row("Project", _dim(_home(str(project_dir))))
|
|
137
|
+
print()
|
|
138
|
+
print(_dim(f" opencode-runtime health {key}"))
|
|
139
|
+
print(_dim(f" opencode-runtime stop {key}"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
143
|
+
try:
|
|
144
|
+
asyncio.run(_serve(args))
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# ps
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_ps(_args: argparse.Namespace) -> None:
|
|
155
|
+
entries = ServerManager().list()
|
|
156
|
+
show_workspace = any(e.workspace for e, _ in entries)
|
|
157
|
+
show_user = any(e.user_id for e, _ in entries)
|
|
158
|
+
|
|
159
|
+
cols = [" {:<18}", "{:>6}", "{:>6}", "{:<7}", "{:>8}"]
|
|
160
|
+
headers = ["ID", "PID", "PORT", "STATUS", "UPTIME"]
|
|
161
|
+
if show_workspace:
|
|
162
|
+
cols.append("{:<12}")
|
|
163
|
+
headers.append("WORKSPACE")
|
|
164
|
+
if show_user:
|
|
165
|
+
cols.append("{:<12}")
|
|
166
|
+
headers.append("USER")
|
|
167
|
+
cols.append("{}")
|
|
168
|
+
headers.append("PROJECT")
|
|
169
|
+
fmt = " ".join(cols)
|
|
170
|
+
|
|
171
|
+
print(_cyan(fmt.format(*headers)))
|
|
172
|
+
print(_dim(" " + "─" * (70 + 14 * show_workspace + 14 * show_user)))
|
|
173
|
+
|
|
174
|
+
for e, alive in entries:
|
|
175
|
+
status_plain = "● alive" if alive else "● dead"
|
|
176
|
+
status_coloured = _green(status_plain) if alive else _red(status_plain)
|
|
177
|
+
vals = [e.key, str(e.pid), str(e.port), status_plain, _uptime(e.started_at, alive)]
|
|
178
|
+
if show_workspace:
|
|
179
|
+
vals.append(e.workspace or "-")
|
|
180
|
+
if show_user:
|
|
181
|
+
vals.append(e.user_id or "-")
|
|
182
|
+
vals.append(_home(e.project_dir))
|
|
183
|
+
row = fmt.format(*vals)
|
|
184
|
+
row = row.replace(status_plain, status_coloured, 1)
|
|
185
|
+
print(_dim(row).replace(_dim(status_coloured), status_coloured, 1))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# stop
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_stop(args: argparse.Namespace) -> None:
|
|
194
|
+
manager = ServerManager()
|
|
195
|
+
entry = manager.find(args.key)
|
|
196
|
+
if entry is None:
|
|
197
|
+
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
|
|
198
|
+
|
|
199
|
+
was_alive = asyncio.run(manager.stop(args.key))
|
|
200
|
+
if not was_alive:
|
|
201
|
+
print(_yellow(f" ● process {entry.pid} was already dead"))
|
|
202
|
+
|
|
203
|
+
print(f"{_green('✓ Server stopped')}\n")
|
|
204
|
+
_row("ID", entry.key)
|
|
205
|
+
_row("PID", _dim(str(entry.pid)))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# stop-all
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_stop_all(_args: argparse.Namespace) -> None:
|
|
214
|
+
manager = ServerManager()
|
|
215
|
+
entries = manager.list()
|
|
216
|
+
if not entries:
|
|
217
|
+
print(_dim(" no servers running"))
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
asyncio.run(manager.stop_all())
|
|
221
|
+
|
|
222
|
+
print(f"{_green(f'✓ Stopped {len(entries)} server(s)')}\n")
|
|
223
|
+
for e, _ in entries:
|
|
224
|
+
print(f" {_dim(e.key)} {_dim(f'pid {e.pid}')}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# health
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_health(args: argparse.Namespace) -> None:
|
|
233
|
+
from .exceptions import OpenCodeServerError
|
|
234
|
+
|
|
235
|
+
manager = ServerManager()
|
|
236
|
+
entry = manager.find(args.key)
|
|
237
|
+
if entry is None:
|
|
238
|
+
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
|
|
239
|
+
|
|
240
|
+
url = f"http://127.0.0.1:{entry.port}"
|
|
241
|
+
try:
|
|
242
|
+
result = asyncio.run(manager.health(args.key))
|
|
243
|
+
version = result.get("version")
|
|
244
|
+
print(_green("✓ healthy") + f" {_dim(f'version {version}')}" + f" {_dim(url)}")
|
|
245
|
+
except OpenCodeServerError as exc:
|
|
246
|
+
sys.exit(_red(f"✗ unreachable {url}\n {exc}"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# main
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main() -> None:
|
|
255
|
+
parser = argparse.ArgumentParser(
|
|
256
|
+
prog="opencode-runtime", description="Manage OpenCode instance processes."
|
|
257
|
+
)
|
|
258
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
259
|
+
sub.required = True
|
|
260
|
+
|
|
261
|
+
p = sub.add_parser("serve", help="start an opencode server (detached)")
|
|
262
|
+
p.add_argument(
|
|
263
|
+
"--project-dir", default=".", metavar="DIR", help="project directory (default: .)"
|
|
264
|
+
)
|
|
265
|
+
p.add_argument(
|
|
266
|
+
"--runtime-dir", default=None, metavar="DIR", help="isolated runtime directory (optional)"
|
|
267
|
+
)
|
|
268
|
+
p.add_argument(
|
|
269
|
+
"--materials",
|
|
270
|
+
action="append",
|
|
271
|
+
metavar="PATH",
|
|
272
|
+
help="materials path(s) to overlay (repeatable)",
|
|
273
|
+
)
|
|
274
|
+
p.add_argument(
|
|
275
|
+
"--workspace", default=None, metavar="NAME", help="tenant workspace identifier (optional)"
|
|
276
|
+
)
|
|
277
|
+
p.add_argument("--user-id", default=None, metavar="ID", help="user identifier (optional)")
|
|
278
|
+
p.set_defaults(func=cmd_serve)
|
|
279
|
+
|
|
280
|
+
p = sub.add_parser("ps", help="list tracked servers")
|
|
281
|
+
p.set_defaults(func=cmd_ps)
|
|
282
|
+
|
|
283
|
+
p = sub.add_parser("stop", help="stop a server by id")
|
|
284
|
+
p.add_argument("key", help="server id (from ps)")
|
|
285
|
+
p.set_defaults(func=cmd_stop)
|
|
286
|
+
|
|
287
|
+
p = sub.add_parser("stop-all", help="stop all tracked servers")
|
|
288
|
+
p.set_defaults(func=cmd_stop_all)
|
|
289
|
+
|
|
290
|
+
p = sub.add_parser("health", help="check health of a server by id")
|
|
291
|
+
p.add_argument("key", help="server id (from ps)")
|
|
292
|
+
p.set_defaults(func=cmd_health)
|
|
293
|
+
|
|
294
|
+
if len(sys.argv) == 1:
|
|
295
|
+
parser.print_help()
|
|
296
|
+
sys.exit(0)
|
|
297
|
+
|
|
298
|
+
args = parser.parse_args()
|
|
299
|
+
args.func(args)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import typing as t
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .event import OpenCodeEvent
|
|
10
|
+
from .exceptions import OpenCodeServerError, OpenCodeTimeoutError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenCodeClient:
|
|
14
|
+
"""Minimal HTTP/SSE client for the OpenCode server.
|
|
15
|
+
|
|
16
|
+
Covers only what the runtime needs to function:
|
|
17
|
+
- health check
|
|
18
|
+
- fire-and-forget message send
|
|
19
|
+
- SSE event stream
|
|
20
|
+
|
|
21
|
+
For anything else use the ``get()`` / ``post()`` escape hatches directly
|
|
22
|
+
against the OpenCode REST API (see https://opencode.ai/docs/server).
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
base_url: Base URL of the running opencode server,
|
|
26
|
+
e.g. ``"http://127.0.0.1:4096"``.
|
|
27
|
+
password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as
|
|
28
|
+
``Authorization: Bearer <password>`` when set.
|
|
29
|
+
timeout: Default request timeout in seconds.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str,
|
|
36
|
+
password: str | None = None,
|
|
37
|
+
timeout: float = 60.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self.password = password
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Escape hatches — use these for any endpoint not covered below
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _headers(self) -> dict[str, str]:
|
|
48
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
49
|
+
if self.password:
|
|
50
|
+
# OpenCode uses HTTP Basic auth: username "opencode", password is the token
|
|
51
|
+
credentials = base64.b64encode(f"opencode:{self.password}".encode()).decode()
|
|
52
|
+
headers["Authorization"] = f"Basic {credentials}"
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def _http(self) -> httpx.AsyncClient:
|
|
56
|
+
return httpx.AsyncClient(
|
|
57
|
+
base_url=self.base_url,
|
|
58
|
+
headers=self._headers(),
|
|
59
|
+
timeout=self.timeout,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def get(self, path: str) -> t.Any:
|
|
63
|
+
"""GET any OpenCode server endpoint. Returns parsed JSON."""
|
|
64
|
+
async with self._http() as http:
|
|
65
|
+
try:
|
|
66
|
+
r = await http.get(path)
|
|
67
|
+
r.raise_for_status()
|
|
68
|
+
return r.json()
|
|
69
|
+
except httpx.TimeoutException as exc:
|
|
70
|
+
raise OpenCodeTimeoutError(f"GET {path} timed out") from exc
|
|
71
|
+
except httpx.HTTPStatusError as exc:
|
|
72
|
+
raise OpenCodeServerError(
|
|
73
|
+
f"GET {path} returned {exc.response.status_code}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
async def post(self, path: str, body: dict[str, t.Any]) -> t.Any:
|
|
77
|
+
"""POST any OpenCode server endpoint. Returns parsed JSON."""
|
|
78
|
+
async with self._http() as http:
|
|
79
|
+
try:
|
|
80
|
+
r = await http.post(path, json=body)
|
|
81
|
+
r.raise_for_status()
|
|
82
|
+
return r.json()
|
|
83
|
+
except httpx.TimeoutException as exc:
|
|
84
|
+
raise OpenCodeTimeoutError(f"POST {path} timed out") from exc
|
|
85
|
+
except httpx.HTTPStatusError as exc:
|
|
86
|
+
raise OpenCodeServerError(
|
|
87
|
+
f"POST {path} returned {exc.response.status_code}"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# The three things the runtime actually needs
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def health(self) -> dict[str, t.Any]:
|
|
95
|
+
"""GET /global/health → ``{"healthy": true, "version": "..."}``."""
|
|
96
|
+
return await self.get("/global/health")
|
|
97
|
+
|
|
98
|
+
async def send(
|
|
99
|
+
self,
|
|
100
|
+
session_id: str,
|
|
101
|
+
message: str,
|
|
102
|
+
*,
|
|
103
|
+
model: str | None = None,
|
|
104
|
+
agent: str | None = None,
|
|
105
|
+
tools: dict[str, bool] | None = None,
|
|
106
|
+
system: str | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""POST /session/:id/prompt_async — non-blocking message send.
|
|
109
|
+
|
|
110
|
+
Returns immediately. Events arrive on :meth:`events`.
|
|
111
|
+
|
|
112
|
+
``model`` accepts either a ``"providerID/modelID"`` shorthand string
|
|
113
|
+
or a raw ``{"providerID": ..., "modelID": ...}`` dict.
|
|
114
|
+
"""
|
|
115
|
+
parts: list[dict[str, t.Any]] = [{"type": "text", "text": message}]
|
|
116
|
+
body: dict[str, t.Any] = {"parts": parts}
|
|
117
|
+
|
|
118
|
+
if model is not None:
|
|
119
|
+
if isinstance(model, str) and "/" in model:
|
|
120
|
+
provider_id, model_id = model.split("/", 1)
|
|
121
|
+
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
|
122
|
+
else:
|
|
123
|
+
body["model"] = model
|
|
124
|
+
if agent is not None:
|
|
125
|
+
body["agent"] = agent
|
|
126
|
+
if tools is not None:
|
|
127
|
+
body["tools"] = tools
|
|
128
|
+
if system is not None:
|
|
129
|
+
body["system"] = system
|
|
130
|
+
|
|
131
|
+
async with self._http() as http:
|
|
132
|
+
try:
|
|
133
|
+
r = await http.post(f"/session/{session_id}/prompt_async", json=body)
|
|
134
|
+
if r.status_code not in (200, 204):
|
|
135
|
+
r.raise_for_status()
|
|
136
|
+
except httpx.TimeoutException as exc:
|
|
137
|
+
raise OpenCodeTimeoutError("send timed out") from exc
|
|
138
|
+
except httpx.HTTPStatusError as exc:
|
|
139
|
+
raise OpenCodeServerError(f"send returned {exc.response.status_code}") from exc
|
|
140
|
+
|
|
141
|
+
async def events(self, session_id: str) -> t.AsyncIterator[OpenCodeEvent]:
|
|
142
|
+
"""GET /global/event — SSE bus filtered to ``session_id``.
|
|
143
|
+
|
|
144
|
+
Yields every :class:`~opencode_runtime.event.OpenCodeEvent` that
|
|
145
|
+
OpenCode emits for this session and terminates on a terminal event.
|
|
146
|
+
|
|
147
|
+
The runtime does **no interpretation** — all events are forwarded as-is
|
|
148
|
+
so callers can handle whatever OpenCode emits (text deltas, tool calls,
|
|
149
|
+
thinking, permission requests, status updates, etc.).
|
|
150
|
+
|
|
151
|
+
Common event types emitted by OpenCode:
|
|
152
|
+
|
|
153
|
+
* ``message.part.delta`` — incremental token; ``event.text`` is the
|
|
154
|
+
delta string when ``properties.field == "text"``.
|
|
155
|
+
* ``message.part.updated`` — full part snapshot (text, tool, thinking,
|
|
156
|
+
…); ``event.text`` is the text content when ``part.type == "text"``.
|
|
157
|
+
* ``session.status`` — status change (e.g. ``{type: "running"}``).
|
|
158
|
+
* ``session.idle`` — terminal; model finished.
|
|
159
|
+
* ``session.error`` — terminal; something went wrong.
|
|
160
|
+
* ``permission.asked`` — tool permission request; caller must handle.
|
|
161
|
+
|
|
162
|
+
Only ``sync`` bus-noise events are suppressed.
|
|
163
|
+
"""
|
|
164
|
+
async with self._http() as http:
|
|
165
|
+
async with http.stream("GET", "/global/event") as r:
|
|
166
|
+
try:
|
|
167
|
+
r.raise_for_status()
|
|
168
|
+
except httpx.HTTPStatusError as exc:
|
|
169
|
+
raise OpenCodeServerError(
|
|
170
|
+
f"SSE /global/event returned {exc.response.status_code}"
|
|
171
|
+
) from exc
|
|
172
|
+
|
|
173
|
+
async for line in r.aiter_lines():
|
|
174
|
+
line = line.strip()
|
|
175
|
+
|
|
176
|
+
if not line.startswith("data:"):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
raw_data = line[len("data:") :].strip()
|
|
180
|
+
if not raw_data:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
envelope = json.loads(raw_data)
|
|
185
|
+
except json.JSONDecodeError:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# /global/event wraps each event: {"payload": {"type": ..., "properties": ...}}
|
|
189
|
+
payload = envelope.get("payload", {})
|
|
190
|
+
if not isinstance(payload, dict):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
event_type = payload.get("type", "")
|
|
194
|
+
|
|
195
|
+
# Suppress internal bus noise — never useful to callers.
|
|
196
|
+
if event_type == "sync":
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
props = payload.get("properties", {})
|
|
200
|
+
if not isinstance(props, dict):
|
|
201
|
+
props = {}
|
|
202
|
+
|
|
203
|
+
# Filter to this session (events without sessionID are global, pass through)
|
|
204
|
+
sid = props.get("sessionID")
|
|
205
|
+
if sid is not None and sid != session_id:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Derive a convenience text field where it naturally exists,
|
|
209
|
+
# so callers don't have to dig into raw for the common case.
|
|
210
|
+
text: str | None = None
|
|
211
|
+
if event_type == "message.part.delta" and props.get("field") == "text":
|
|
212
|
+
text = props.get("delta") or None
|
|
213
|
+
elif event_type == "message.part.updated":
|
|
214
|
+
part = props.get("part") or {}
|
|
215
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
216
|
+
text = part.get("text") or None
|
|
217
|
+
|
|
218
|
+
yield OpenCodeEvent(type=event_type, text=text, raw=payload)
|
|
219
|
+
|
|
220
|
+
# Terminal events — stop iterating after yielding.
|
|
221
|
+
if event_type == "session.idle":
|
|
222
|
+
return
|
|
223
|
+
if event_type == "session.error":
|
|
224
|
+
return
|
|
225
|
+
if event_type == "session.status":
|
|
226
|
+
status = props.get("status", {})
|
|
227
|
+
if isinstance(status, dict) and status.get("type") == "idle":
|
|
228
|
+
return
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class OpenCodeEvent:
|
|
9
|
+
"""A normalized event from the OpenCode server SSE stream.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
type: Event type string, e.g. "message.delta", "message.completed",
|
|
13
|
+
"session.error". Mirrors the OpenCode bus event types.
|
|
14
|
+
text: Text content for message.delta events; None for other types.
|
|
15
|
+
raw: The raw event payload from the server. Use this escape hatch
|
|
16
|
+
when you need fields beyond type and text.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
type: str
|
|
20
|
+
text: str | None = None
|
|
21
|
+
raw: Any = None
|
|
22
|
+
|
|
23
|
+
def to_sse(self) -> str:
|
|
24
|
+
"""Format as a Server-Sent Events string suitable for HTTP streaming."""
|
|
25
|
+
data = self.text or ""
|
|
26
|
+
return f"event: {self.type}\ndata: {data}\n\n"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class OpenCodeRuntimeError(Exception):
|
|
2
|
+
"""Base class for all opencode-runtime errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OpenCodeNotFoundError(OpenCodeRuntimeError):
|
|
6
|
+
"""Raised when the opencode binary cannot be found on PATH."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenCodeServerError(OpenCodeRuntimeError):
|
|
10
|
+
"""Raised when the opencode server fails to start or returns an error."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenCodeTimeoutError(OpenCodeRuntimeError):
|
|
14
|
+
"""Raised when a health check or request exceeds the allowed timeout."""
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry for tracking OpenCode instance processes.
|
|
3
|
+
|
|
4
|
+
Each running instance is represented by a JSON file at:
|
|
5
|
+
~/.opencode-runtime/servers/<key>.json
|
|
6
|
+
|
|
7
|
+
Used by both the CLI (opencode-runtime serve/ps/stop) and the library
|
|
8
|
+
(OpenCodeRuntime) — the registry is the shared source of truth for all
|
|
9
|
+
running instances regardless of how they were started.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
REGISTRY_DIR = Path.home() / ".opencode-runtime" / "servers"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RegistryEntry:
|
|
25
|
+
key: str
|
|
26
|
+
pid: int
|
|
27
|
+
port: int
|
|
28
|
+
password: str
|
|
29
|
+
project_dir: str
|
|
30
|
+
server_dir: str | None
|
|
31
|
+
started_at: str # ISO-8601
|
|
32
|
+
workspace: str | None = None
|
|
33
|
+
user_id: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write(entry: RegistryEntry) -> None:
|
|
37
|
+
"""Write a registry entry to disk. File is chmod 0o600."""
|
|
38
|
+
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path = REGISTRY_DIR / f"{entry.key}.json"
|
|
40
|
+
path.write_text(json.dumps(asdict(entry), indent=2), encoding="utf-8")
|
|
41
|
+
path.chmod(0o600)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read(key: str) -> RegistryEntry | None:
|
|
45
|
+
"""Read a registry entry by key. Returns None if not found."""
|
|
46
|
+
path = REGISTRY_DIR / f"{key}.json"
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return None
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
return RegistryEntry(**data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def delete(key: str) -> None:
|
|
54
|
+
"""Remove a registry entry. No-op if not found."""
|
|
55
|
+
path = REGISTRY_DIR / f"{key}.json"
|
|
56
|
+
path.unlink(missing_ok=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_all() -> list[RegistryEntry]:
|
|
60
|
+
"""Return all registry entries on disk."""
|
|
61
|
+
if not REGISTRY_DIR.exists():
|
|
62
|
+
return []
|
|
63
|
+
entries = []
|
|
64
|
+
for path in REGISTRY_DIR.glob("*.json"):
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
entries.append(RegistryEntry(**data))
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
return entries
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_alive(pid: int) -> bool:
|
|
74
|
+
"""Return True if a process with this PID is running."""
|
|
75
|
+
try:
|
|
76
|
+
os.kill(pid, 0)
|
|
77
|
+
return True
|
|
78
|
+
except (ProcessLookupError, PermissionError):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def now_iso() -> str:
|
|
83
|
+
"""Return current UTC time as ISO-8601 string."""
|
|
84
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class OpenCodeResponse:
|
|
9
|
+
"""Collected response from a completed ask() call.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
text: Full assistant text, concatenated from all message.delta events.
|
|
13
|
+
raw: List of raw event objects received during the session, in order.
|
|
14
|
+
Use this as an escape hatch when you need parts beyond plain text.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
text: str
|
|
18
|
+
raw: list[Any] = field(default_factory=list)
|