opencode-python-sdk 0.2.0__py3-none-any.whl → 0.2.2__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/__init__.py +5 -0
- opencode/_client.py +107 -1
- opencode/_service.py +1018 -0
- opencode/service.py +50 -0
- {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.2.dist-info}/METADATA +22 -1
- {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.2.dist-info}/RECORD +8 -6
- {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.2.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
from typing import TYPE_CHECKING, Any
|
|
18
18
|
|
|
19
|
+
from . import service
|
|
19
20
|
from ._client import AsyncOpencode, Opencode
|
|
20
21
|
|
|
21
22
|
# <generated:error-imports>
|
|
@@ -78,6 +79,7 @@ from ._models import BaseModel
|
|
|
78
79
|
from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
|
|
79
80
|
from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
|
|
80
81
|
from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
|
|
82
|
+
from ._service import Endpoint, ServiceError
|
|
81
83
|
from ._streaming import AsyncStream, Stream
|
|
82
84
|
from ._types import NOT_GIVEN, NotGiven
|
|
83
85
|
from ._version import __version__
|
|
@@ -136,6 +138,7 @@ __all__ = [
|
|
|
136
138
|
"BaseModel",
|
|
137
139
|
"ConflictError",
|
|
138
140
|
"ContentTooLargeError",
|
|
141
|
+
"Endpoint",
|
|
139
142
|
"InternalServerError",
|
|
140
143
|
"NotFoundError",
|
|
141
144
|
"NotGiven",
|
|
@@ -148,11 +151,13 @@ __all__ = [
|
|
|
148
151
|
"PtyConnectionError",
|
|
149
152
|
"RateLimitError",
|
|
150
153
|
"ServerStartError",
|
|
154
|
+
"ServiceError",
|
|
151
155
|
"ServiceUnavailableError",
|
|
152
156
|
"Stream",
|
|
153
157
|
"SyncCursorPage",
|
|
154
158
|
"UnprocessableEntityError",
|
|
155
159
|
"__version__",
|
|
160
|
+
"service",
|
|
156
161
|
"types",
|
|
157
162
|
# Shared event stream and typed plugin RPC
|
|
158
163
|
"SUBSCRIBER_CAPACITY",
|
opencode/_client.py
CHANGED
|
@@ -9,12 +9,21 @@ from ._base_client import AsyncAPIClient, SyncAPIClient
|
|
|
9
9
|
from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
|
|
10
10
|
from ._events import AsyncEventHub, EventHub
|
|
11
11
|
from ._rpc import AsyncTypedRpcResource, TypedRpcResource
|
|
12
|
+
from ._service import (
|
|
13
|
+
EnsureReason,
|
|
14
|
+
ServiceError,
|
|
15
|
+
Version,
|
|
16
|
+
async_discover,
|
|
17
|
+
async_ensure,
|
|
18
|
+
discover as discover_service,
|
|
19
|
+
ensure as ensure_service,
|
|
20
|
+
)
|
|
12
21
|
from ._types import NOT_GIVEN, NotGiven, Timeout
|
|
13
22
|
from .resources import AsyncResources, SyncResources
|
|
14
23
|
|
|
15
24
|
if TYPE_CHECKING:
|
|
16
25
|
import os
|
|
17
|
-
from collections.abc import Iterable, Mapping
|
|
26
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
18
27
|
|
|
19
28
|
import httpx
|
|
20
29
|
|
|
@@ -82,6 +91,67 @@ class Opencode(SyncAPIClient, SyncResources):
|
|
|
82
91
|
transport=transport,
|
|
83
92
|
)
|
|
84
93
|
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_service(
|
|
96
|
+
cls,
|
|
97
|
+
*,
|
|
98
|
+
start: bool = True,
|
|
99
|
+
file: str | os.PathLike[str] | None = None,
|
|
100
|
+
version: Version | None = None,
|
|
101
|
+
command: Sequence[str] | None = None,
|
|
102
|
+
env: Mapping[str, str] | None = None,
|
|
103
|
+
on_start: Callable[[EnsureReason, str | None], None] | None = None,
|
|
104
|
+
directory: str | os.PathLike[str] | None = None,
|
|
105
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
106
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
107
|
+
default_headers: Mapping[str, str] | None = None,
|
|
108
|
+
default_query: Mapping[str, object] | None = None,
|
|
109
|
+
) -> Self:
|
|
110
|
+
"""Connect to the local background service, starting ``opencode serve --service`` if needed.
|
|
111
|
+
|
|
112
|
+
Uses :func:`opencode.service.ensure`, or :func:`opencode.service.discover` when ``start`` is false, and
|
|
113
|
+
sends the service's password with every request.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
start: Start or replace the service when no matching one is running. When false, raise instead.
|
|
117
|
+
file: Registration file. Defaults to ``$XDG_STATE_HOME/opencode/service.json``.
|
|
118
|
+
version: Required exact version, or a predicate on the service version.
|
|
119
|
+
command: Service command and arguments, used when starting it.
|
|
120
|
+
env: Variables added to the environment of a started service.
|
|
121
|
+
on_start: Called once before a service process is started.
|
|
122
|
+
directory: Default project directory, as in the constructor.
|
|
123
|
+
timeout: Default request timeout, as in the constructor.
|
|
124
|
+
max_retries: Retries for failed requests, as in the constructor.
|
|
125
|
+
default_headers: Headers sent with every request.
|
|
126
|
+
default_query: Query parameters sent with every request.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ServiceError: No matching service is running and ``start`` is false, or it could not be started.
|
|
130
|
+
|
|
131
|
+
Example::
|
|
132
|
+
|
|
133
|
+
with Opencode.from_service() as client:
|
|
134
|
+
print(client.server.info().version)
|
|
135
|
+
"""
|
|
136
|
+
if start:
|
|
137
|
+
endpoint = ensure_service(file, version, command=command, env=env, on_start=on_start)
|
|
138
|
+
else:
|
|
139
|
+
found = discover_service(file, version)
|
|
140
|
+
if found is None:
|
|
141
|
+
raise ServiceError("No matching OpenCode background service is running")
|
|
142
|
+
endpoint = found
|
|
143
|
+
return cls(
|
|
144
|
+
endpoint.url,
|
|
145
|
+
# An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
|
|
146
|
+
password="" if endpoint.auth is None else endpoint.auth.password,
|
|
147
|
+
username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
|
|
148
|
+
directory=directory,
|
|
149
|
+
timeout=timeout,
|
|
150
|
+
max_retries=max_retries,
|
|
151
|
+
default_headers=default_headers,
|
|
152
|
+
default_query=default_query,
|
|
153
|
+
)
|
|
154
|
+
|
|
85
155
|
@cached_property
|
|
86
156
|
def events(self) -> EventHub:
|
|
87
157
|
"""The shared event stream: one connection for every subscriber. See :class:`opencode.EventHub`."""
|
|
@@ -189,6 +259,42 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
|
|
|
189
259
|
transport=transport,
|
|
190
260
|
)
|
|
191
261
|
|
|
262
|
+
@classmethod
|
|
263
|
+
async def from_service(
|
|
264
|
+
cls,
|
|
265
|
+
*,
|
|
266
|
+
start: bool = True,
|
|
267
|
+
file: str | os.PathLike[str] | None = None,
|
|
268
|
+
version: Version | None = None,
|
|
269
|
+
command: Sequence[str] | None = None,
|
|
270
|
+
env: Mapping[str, str] | None = None,
|
|
271
|
+
on_start: Callable[[EnsureReason, str | None], None] | None = None,
|
|
272
|
+
directory: str | os.PathLike[str] | None = None,
|
|
273
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
274
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
275
|
+
default_headers: Mapping[str, str] | None = None,
|
|
276
|
+
default_query: Mapping[str, object] | None = None,
|
|
277
|
+
) -> Self:
|
|
278
|
+
"""Async version of :meth:`Opencode.from_service`. Await it: ``client = await AsyncOpencode.from_service()``."""
|
|
279
|
+
if start:
|
|
280
|
+
endpoint = await async_ensure(file, version, command=command, env=env, on_start=on_start)
|
|
281
|
+
else:
|
|
282
|
+
found = await async_discover(file, version)
|
|
283
|
+
if found is None:
|
|
284
|
+
raise ServiceError("No matching OpenCode background service is running")
|
|
285
|
+
endpoint = found
|
|
286
|
+
return cls(
|
|
287
|
+
endpoint.url,
|
|
288
|
+
# An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
|
|
289
|
+
password="" if endpoint.auth is None else endpoint.auth.password,
|
|
290
|
+
username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
|
|
291
|
+
directory=directory,
|
|
292
|
+
timeout=timeout,
|
|
293
|
+
max_retries=max_retries,
|
|
294
|
+
default_headers=default_headers,
|
|
295
|
+
default_query=default_query,
|
|
296
|
+
)
|
|
297
|
+
|
|
192
298
|
@cached_property
|
|
193
299
|
def events(self) -> AsyncEventHub:
|
|
194
300
|
"""The shared event stream: one connection for every subscriber. See :class:`opencode.AsyncEventHub`."""
|
opencode/_service.py
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
"""Find, start and stop the local OpenCode background service (``opencode serve --service``).
|
|
2
|
+
|
|
3
|
+
A port of ``packages/client/src/promise/service.ts`` and its helpers from the OpenCode TypeScript client. The
|
|
4
|
+
registration file written by the service is the whole discovery contract: it names the URL, PID, version and
|
|
5
|
+
password of the one service that owns it.
|
|
6
|
+
|
|
7
|
+
The API is a set of module functions (``discover``, ``ensure``, ``stop``, ``headers``) with ``async_`` twins,
|
|
8
|
+
because the operations share no state between calls: a class would only be a namespace, and the module already is
|
|
9
|
+
one. ``from opencode import service`` gives the same ``service.ensure()`` spelling as the TypeScript ``Service``
|
|
10
|
+
object.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from anyio import sleep as _async_sleep
|
|
34
|
+
|
|
35
|
+
from ._constants import DEFAULT_USERNAME
|
|
36
|
+
from ._exceptions import OpencodeError
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BasicAuth",
|
|
40
|
+
"Endpoint",
|
|
41
|
+
"EnsureReason",
|
|
42
|
+
"PtyMode",
|
|
43
|
+
"ServiceError",
|
|
44
|
+
"Version",
|
|
45
|
+
"async_discover",
|
|
46
|
+
"async_ensure",
|
|
47
|
+
"async_stop",
|
|
48
|
+
"default_file",
|
|
49
|
+
"discover",
|
|
50
|
+
"ensure",
|
|
51
|
+
"headers",
|
|
52
|
+
"stop",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
log = logging.getLogger("opencode")
|
|
56
|
+
_sleep = time.sleep
|
|
57
|
+
|
|
58
|
+
EnsureReason: TypeAlias = Literal["missing", "version-mismatch"]
|
|
59
|
+
"""Why :func:`ensure` starts a new service process."""
|
|
60
|
+
|
|
61
|
+
Version: TypeAlias = str | Callable[[str], bool]
|
|
62
|
+
"""A required exact service version, or a predicate that accepts compatible versions."""
|
|
63
|
+
|
|
64
|
+
PtyMode: TypeAlias = Literal["clear", "handoff"]
|
|
65
|
+
"""How :func:`stop` treats persistent terminals."""
|
|
66
|
+
|
|
67
|
+
_STDERR_LIMIT = 8 * 1024
|
|
68
|
+
_HANDOFF_TTL_MS = 30_000
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServiceError(OpencodeError):
|
|
72
|
+
"""The local service could not be found, started or stopped."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class BasicAuth:
|
|
77
|
+
"""HTTP basic credentials of a local service."""
|
|
78
|
+
|
|
79
|
+
username: str
|
|
80
|
+
"""Basic auth user name, always ``opencode``."""
|
|
81
|
+
password: str = field(repr=False)
|
|
82
|
+
"""The service's private password. Hidden from ``repr`` so it stays out of logs."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Endpoint:
|
|
87
|
+
"""Connection details of a local OpenCode service.
|
|
88
|
+
|
|
89
|
+
Pass it to :meth:`opencode.Opencode.from_service`, or use ``url`` and :func:`headers` with any HTTP client.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
url: str
|
|
93
|
+
"""Base URL of the service."""
|
|
94
|
+
auth: BasicAuth | None = None
|
|
95
|
+
"""Credentials the service requires, or ``None`` when it runs without a password."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class _Info:
|
|
100
|
+
url: str
|
|
101
|
+
pid: int
|
|
102
|
+
id: str | None = None
|
|
103
|
+
version: str | None = None
|
|
104
|
+
password: str | None = field(default=None, repr=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class _LocalService:
|
|
109
|
+
info: _Info
|
|
110
|
+
endpoint: Endpoint
|
|
111
|
+
version: str | None
|
|
112
|
+
state: Literal["ready", "waiting", "failed"]
|
|
113
|
+
compatible: bool
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class _Probe:
|
|
118
|
+
service: _LocalService | None = None
|
|
119
|
+
timed_out: bool = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class _Registration:
|
|
124
|
+
info: _Info | None = None
|
|
125
|
+
service: _LocalService | None = None
|
|
126
|
+
timed_out: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class _Timing:
|
|
131
|
+
# Seconds. A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the time a
|
|
132
|
+
# client waits for it.
|
|
133
|
+
poll_interval: float = 0.025
|
|
134
|
+
request_timeout: float = 2.0
|
|
135
|
+
spawn_delay: float = 5.0
|
|
136
|
+
max_spawn_delay: float = 30.0
|
|
137
|
+
timeout: float = 120.0
|
|
138
|
+
stop_poll_interval: float = 0.05
|
|
139
|
+
stop_poll_attempts: int = 100
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Tests replace this to keep the lifecycle fast; it is not part of the public signature, as in TypeScript.
|
|
143
|
+
_TIMING = _Timing()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def default_file() -> Path:
|
|
147
|
+
"""The default registration file, ``$XDG_STATE_HOME/opencode/service.json``.
|
|
148
|
+
|
|
149
|
+
``XDG_STATE_HOME`` defaults to ``~/.local/state`` on every platform, as in OpenCode itself.
|
|
150
|
+
"""
|
|
151
|
+
state = os.environ.get("XDG_STATE_HOME")
|
|
152
|
+
return Path(state if state is not None else Path.home() / ".local" / "state") / "opencode" / "service.json"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def headers(endpoint: Endpoint) -> dict[str, str] | None:
|
|
156
|
+
"""The ``Authorization`` header for ``endpoint``, or ``None`` when it needs no credentials."""
|
|
157
|
+
if endpoint.auth is None:
|
|
158
|
+
return None
|
|
159
|
+
return {"authorization": _basic(endpoint.auth.username, endpoint.auth.password)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _basic(username: str, password: str) -> str:
|
|
163
|
+
return "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _file(file: str | os.PathLike[str] | None) -> Path:
|
|
167
|
+
return Path(file) if file is not None else default_file()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _now_ms() -> float:
|
|
171
|
+
return time.time() * 1000
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- versions and probing (service-version.ts, service-probe.ts) ---
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _matches_version(version: str | None, required: Version | None) -> bool:
|
|
178
|
+
if required is None:
|
|
179
|
+
return True
|
|
180
|
+
if version is None:
|
|
181
|
+
return False
|
|
182
|
+
if callable(required):
|
|
183
|
+
return required(version)
|
|
184
|
+
return version == required
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class _Decision:
|
|
189
|
+
tag: Literal["reuse", "wait", "replace", "fail"]
|
|
190
|
+
pty: PtyMode = "clear"
|
|
191
|
+
error: str = ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _decide(service: _LocalService, required: Version | None) -> _Decision:
|
|
195
|
+
if not _matches_version(service.version, required):
|
|
196
|
+
return _Decision("replace", pty="handoff" if service.state == "ready" else "clear")
|
|
197
|
+
if not service.compatible:
|
|
198
|
+
return _Decision(
|
|
199
|
+
"fail",
|
|
200
|
+
error="Background service uses an incompatible health protocol. "
|
|
201
|
+
"Update this client or explicitly restart the service.",
|
|
202
|
+
)
|
|
203
|
+
if service.state == "ready":
|
|
204
|
+
return _Decision("reuse")
|
|
205
|
+
if service.state == "failed":
|
|
206
|
+
return _Decision("fail", error="Background service failed to start")
|
|
207
|
+
return _Decision("wait")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _same(left: _Info, right: _Info) -> bool:
|
|
211
|
+
return left.id == right.id and left.version == right.version and left.url == right.url and left.pid == right.pid
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _endpoint(info: _Info) -> Endpoint:
|
|
215
|
+
return Endpoint(info.url, None if info.password is None else BasicAuth(DEFAULT_USERNAME, info.password))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _decode_server_info(body: object) -> tuple[str, int] | None:
|
|
219
|
+
if not isinstance(body, dict):
|
|
220
|
+
return None
|
|
221
|
+
data = cast("dict[str, object]", body)
|
|
222
|
+
version, pid = data.get("version"), data.get("pid")
|
|
223
|
+
if not isinstance(version, str):
|
|
224
|
+
return None
|
|
225
|
+
if isinstance(pid, bool) or not isinstance(pid, (int, float)) or pid != int(pid) or pid < 0:
|
|
226
|
+
return None
|
|
227
|
+
return version, int(pid)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _classify(info: _Info, status: int, body: object) -> _Probe:
|
|
231
|
+
endpoint = _endpoint(info)
|
|
232
|
+
# A missing health endpoint identifies protocol incompatibility, not an older version. Only an unmet version
|
|
233
|
+
# requirement lets ensure replace this owner.
|
|
234
|
+
if status == 404:
|
|
235
|
+
return _Probe(_LocalService(info, endpoint, info.version, "ready", compatible=False))
|
|
236
|
+
decoded = _decode_server_info(body)
|
|
237
|
+
if decoded is None:
|
|
238
|
+
return _Probe()
|
|
239
|
+
version, pid = decoded
|
|
240
|
+
if pid != info.pid:
|
|
241
|
+
return _Probe()
|
|
242
|
+
if info.version is not None and version != info.version:
|
|
243
|
+
return _Probe()
|
|
244
|
+
state: Literal["ready", "waiting", "failed"] = (
|
|
245
|
+
"ready" if 200 <= status < 300 else "failed" if status == 500 else "waiting"
|
|
246
|
+
)
|
|
247
|
+
return _Probe(_LocalService(info, endpoint, version, state, compatible=True))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _info_url(info: _Info) -> httpx.URL:
|
|
251
|
+
return httpx.URL(info.url).join("/api/info")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _probe(info: _Info, timeout: float) -> _Probe:
|
|
255
|
+
# The service is local: ignore proxy variables, as Node's fetch in the TypeScript client does.
|
|
256
|
+
try:
|
|
257
|
+
with httpx.Client(timeout=timeout, trust_env=False) as http:
|
|
258
|
+
response = http.get(_info_url(info), headers=headers(_endpoint(info)))
|
|
259
|
+
body: object = None if response.status_code == 404 else response.json()
|
|
260
|
+
except httpx.TimeoutException:
|
|
261
|
+
return _Probe(timed_out=True)
|
|
262
|
+
except (httpx.HTTPError, ValueError):
|
|
263
|
+
return _Probe()
|
|
264
|
+
return _classify(info, response.status_code, body)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _async_probe(info: _Info, timeout: float) -> _Probe:
|
|
268
|
+
try:
|
|
269
|
+
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as http:
|
|
270
|
+
response = await http.get(_info_url(info), headers=headers(_endpoint(info)))
|
|
271
|
+
body: object = None if response.status_code == 404 else response.json()
|
|
272
|
+
except httpx.TimeoutException:
|
|
273
|
+
return _Probe(timed_out=True)
|
|
274
|
+
except (httpx.HTTPError, ValueError):
|
|
275
|
+
return _Probe()
|
|
276
|
+
return _classify(info, response.status_code, body)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- registration file ---
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _read_json(path: Path) -> object:
|
|
283
|
+
try:
|
|
284
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
285
|
+
except (OSError, ValueError):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _parse_info(value: object) -> _Info | None:
|
|
290
|
+
if not isinstance(value, dict):
|
|
291
|
+
return None
|
|
292
|
+
data = cast("dict[str, object]", value)
|
|
293
|
+
url, pid = data.get("url"), data.get("pid")
|
|
294
|
+
if not isinstance(url, str) or isinstance(pid, bool) or not isinstance(pid, int):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def text(key: str) -> str | None:
|
|
298
|
+
item = data.get(key)
|
|
299
|
+
return item if isinstance(item, str) else None
|
|
300
|
+
|
|
301
|
+
return _Info(url=url, pid=pid, id=text("id"), version=text("version"), password=text("password"))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _read(file: Path) -> _Info | None:
|
|
305
|
+
return _parse_info(_read_json(file))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _registered(file: Path, timeout: float) -> _Registration:
|
|
309
|
+
info = _read(file)
|
|
310
|
+
if info is None:
|
|
311
|
+
return _Registration()
|
|
312
|
+
probe = _probe(info, timeout)
|
|
313
|
+
return _Registration(info, probe.service, probe.timed_out)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def _async_registered(file: Path, timeout: float) -> _Registration:
|
|
317
|
+
info = _read(file)
|
|
318
|
+
if info is None:
|
|
319
|
+
return _Registration()
|
|
320
|
+
probe = await _async_probe(info, timeout)
|
|
321
|
+
return _Registration(info, probe.service, probe.timed_out)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _discovered(registration: _Registration, version: Version | None) -> Endpoint | None:
|
|
325
|
+
found = registration.service
|
|
326
|
+
if found is None or found.state != "ready" or not found.compatible:
|
|
327
|
+
return None
|
|
328
|
+
if not _matches_version(found.version, version):
|
|
329
|
+
return None
|
|
330
|
+
return found.endpoint
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --- persistent terminal handoff (pty-handoff.ts) ---
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass(frozen=True)
|
|
337
|
+
class _Source:
|
|
338
|
+
pid: int
|
|
339
|
+
url: str
|
|
340
|
+
id: str | None = None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class _Sidecar:
|
|
345
|
+
source: _Source
|
|
346
|
+
handoff: dict[str, Any] | None
|
|
347
|
+
expires_at: float
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _sidecar_path(file: Path) -> Path:
|
|
351
|
+
return file.with_name(file.name + ".pty-handoff")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_handoff(value: object) -> bool:
|
|
355
|
+
if not isinstance(value, dict):
|
|
356
|
+
return False
|
|
357
|
+
data = cast("dict[str, object]", value)
|
|
358
|
+
expires = data.get("expiresAt")
|
|
359
|
+
return (
|
|
360
|
+
isinstance(data.get("directory"), str)
|
|
361
|
+
and isinstance(data.get("instanceID"), str)
|
|
362
|
+
and isinstance(data.get("ticket"), str)
|
|
363
|
+
and _is_number(expires)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_number(value: object) -> bool:
|
|
368
|
+
return (
|
|
369
|
+
isinstance(value, (int, float))
|
|
370
|
+
and not isinstance(value, bool)
|
|
371
|
+
and value == value
|
|
372
|
+
and abs(value) != float("inf")
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _read_sidecar(file: Path) -> _Sidecar | None:
|
|
377
|
+
value = _read_json(_sidecar_path(file))
|
|
378
|
+
if not isinstance(value, dict):
|
|
379
|
+
return None
|
|
380
|
+
data = cast("dict[str, object]", value)
|
|
381
|
+
if "source" not in data or "handoff" not in data:
|
|
382
|
+
return None
|
|
383
|
+
source = data["source"]
|
|
384
|
+
if not isinstance(source, dict):
|
|
385
|
+
return None
|
|
386
|
+
src = cast("dict[str, object]", source)
|
|
387
|
+
pid, url, id_ = src.get("pid"), src.get("url"), src.get("id")
|
|
388
|
+
if not _is_number(pid) or not isinstance(url, str):
|
|
389
|
+
return None
|
|
390
|
+
if "id" in src and not isinstance(id_, str):
|
|
391
|
+
return None
|
|
392
|
+
handoff = data["handoff"]
|
|
393
|
+
if handoff is not None and not _is_handoff(handoff):
|
|
394
|
+
return None
|
|
395
|
+
expires = data.get("expiresAt")
|
|
396
|
+
if not _is_number(expires):
|
|
397
|
+
return None
|
|
398
|
+
return _Sidecar(
|
|
399
|
+
_Source(pid=cast("int", pid), url=url, id=id_ if isinstance(id_, str) else None),
|
|
400
|
+
cast("dict[str, Any] | None", handoff),
|
|
401
|
+
cast("float", expires),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _same_source(source: _Source, info: _Info) -> bool:
|
|
406
|
+
return source.id == info.id and source.pid == info.pid and source.url == info.url
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _fresh_for(record: _Sidecar | None, info: _Info) -> bool:
|
|
410
|
+
return record is not None and record.expires_at > _now_ms() and _same_source(record.source, info)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _parse_handoff(body: object) -> dict[str, Any] | None:
|
|
414
|
+
if not isinstance(body, dict) or "handoff" not in body:
|
|
415
|
+
raise ValueError("Invalid persistent terminal handoff response")
|
|
416
|
+
handoff = cast("dict[str, object]", body)["handoff"]
|
|
417
|
+
if handoff is None:
|
|
418
|
+
return None
|
|
419
|
+
if not _is_handoff(handoff) or cast("dict[str, Any]", handoff)["expiresAt"] <= _now_ms():
|
|
420
|
+
raise ValueError("Invalid or expired persistent terminal handoff")
|
|
421
|
+
return cast("dict[str, Any]", handoff)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _publish(file: Path, info: _Info, handoff: dict[str, Any] | None) -> None:
|
|
425
|
+
temporary = file.with_name(f"{file.name}.pty-handoff.{uuid.uuid4()}.tmp")
|
|
426
|
+
record = {
|
|
427
|
+
"source": {
|
|
428
|
+
key: value for key, value in (("id", info.id), ("pid", info.pid), ("url", info.url)) if value is not None
|
|
429
|
+
},
|
|
430
|
+
"handoff": handoff,
|
|
431
|
+
"expiresAt": handoff["expiresAt"] if handoff is not None else _now_ms() + _HANDOFF_TTL_MS,
|
|
432
|
+
}
|
|
433
|
+
try:
|
|
434
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
435
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
436
|
+
json.dump(record, stream, separators=(",", ":"))
|
|
437
|
+
os.replace(temporary, _sidecar_path(file))
|
|
438
|
+
finally:
|
|
439
|
+
with contextlib.suppress(FileNotFoundError):
|
|
440
|
+
temporary.unlink()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _pty_url(info: _Info, action: str) -> httpx.URL:
|
|
444
|
+
return httpx.URL(info.url).join(f"/api/experimental/persistent-pty/{action}")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _pty_headers(info: _Info) -> dict[str, str] | None:
|
|
448
|
+
return None if info.password is None else {"authorization": _basic(DEFAULT_USERNAME, info.password)}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _handoff_failed(file: Path, info: _Info, cause: Exception) -> bool:
|
|
452
|
+
"""Whether the old service's terminals must be shut down, after a failed handoff request."""
|
|
453
|
+
# Another caller may already have prepared and stopped this server.
|
|
454
|
+
if _fresh_for(_read_sidecar(file), info):
|
|
455
|
+
return False
|
|
456
|
+
log.warning(
|
|
457
|
+
"Background service cannot hand off persistent terminals; shutting them down before replacement: %s", cause
|
|
458
|
+
)
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _publish_logged(file: Path, info: _Info, handoff: dict[str, Any] | None) -> None:
|
|
463
|
+
try:
|
|
464
|
+
_publish(file, info, handoff)
|
|
465
|
+
except OSError as cause:
|
|
466
|
+
log.warning("Failed to publish persistent terminal handoff: %s", cause)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _pty_prepare(file: Path, info: _Info, timeout: float) -> None:
|
|
470
|
+
# Publish the ticket before stopping its owner so every replacement contender can adopt it.
|
|
471
|
+
if _fresh_for(_read_sidecar(file), info):
|
|
472
|
+
return
|
|
473
|
+
with httpx.Client(timeout=timeout, trust_env=False, headers=_pty_headers(info)) as http:
|
|
474
|
+
try:
|
|
475
|
+
response = http.post(_pty_url(info, "handoff"))
|
|
476
|
+
response.raise_for_status()
|
|
477
|
+
handoff = _parse_handoff(response.json())
|
|
478
|
+
except (httpx.HTTPError, ValueError) as cause:
|
|
479
|
+
if not _handoff_failed(file, info, cause):
|
|
480
|
+
return
|
|
481
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
482
|
+
http.post(_pty_url(info, "shutdown"))
|
|
483
|
+
handoff = None
|
|
484
|
+
_publish_logged(file, info, handoff)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
async def _async_pty_prepare(file: Path, info: _Info, timeout: float) -> None:
|
|
488
|
+
if _fresh_for(_read_sidecar(file), info):
|
|
489
|
+
return
|
|
490
|
+
async with httpx.AsyncClient(timeout=timeout, trust_env=False, headers=_pty_headers(info)) as http:
|
|
491
|
+
try:
|
|
492
|
+
response = await http.post(_pty_url(info, "handoff"))
|
|
493
|
+
response.raise_for_status()
|
|
494
|
+
handoff = _parse_handoff(response.json())
|
|
495
|
+
except (httpx.HTTPError, ValueError) as cause:
|
|
496
|
+
if not _handoff_failed(file, info, cause):
|
|
497
|
+
return
|
|
498
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
499
|
+
await http.post(_pty_url(info, "shutdown"))
|
|
500
|
+
handoff = None
|
|
501
|
+
_publish_logged(file, info, handoff)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _pty_environment(file: Path, env: Mapping[str, str] | None) -> dict[str, str | None]:
|
|
505
|
+
record = _read_sidecar(file)
|
|
506
|
+
current = _read_json(file)
|
|
507
|
+
usable = record is not None and record.expires_at > _now_ms()
|
|
508
|
+
if usable and record is not None and isinstance(current, dict):
|
|
509
|
+
data = cast("dict[str, object]", current)
|
|
510
|
+
usable = record.source.id == data.get("id") and record.source.pid == data.get("pid")
|
|
511
|
+
usable = usable and record.source.url == data.get("url")
|
|
512
|
+
handoff = record.handoff if usable and record is not None else None
|
|
513
|
+
return {
|
|
514
|
+
**(env or {}),
|
|
515
|
+
"OPENCODE_PTY_HANDOFF": None if handoff is None else json.dumps(handoff, separators=(",", ":")),
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _pty_complete(file: Path, info: _Info) -> None:
|
|
520
|
+
record = _read_sidecar(file)
|
|
521
|
+
if record is not None and not _same_source(record.source, info):
|
|
522
|
+
_pty_clear(file)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _pty_clear(file: Path) -> None:
|
|
526
|
+
with contextlib.suppress(FileNotFoundError):
|
|
527
|
+
_sidecar_path(file).unlink()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# --- startup attempts (service-contender.ts) ---
|
|
531
|
+
|
|
532
|
+
# Detached service processes this interpreter started. Keeping them referenced until they are reaped stops
|
|
533
|
+
# ``subprocess`` from warning about a still-running child when the ``Popen`` object is collected.
|
|
534
|
+
_children: set[subprocess.Popen[bytes]] = set()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class _Contender:
|
|
538
|
+
"""One ``opencode serve --service`` attempt. Losers of the startup race exit with code 0."""
|
|
539
|
+
|
|
540
|
+
def __init__(self, command: Sequence[str], env: Mapping[str, str | None]) -> None:
|
|
541
|
+
merged = {**os.environ, **env}
|
|
542
|
+
environment = {key: value for key, value in merged.items() if value is not None}
|
|
543
|
+
executable = _resolve(command[0], environment.get("PATH", os.defpath))
|
|
544
|
+
if sys.platform == "win32": # pragma: no cover - Windows only
|
|
545
|
+
self.process = subprocess.Popen(
|
|
546
|
+
[executable, *command[1:]],
|
|
547
|
+
stdin=subprocess.DEVNULL,
|
|
548
|
+
stdout=subprocess.DEVNULL,
|
|
549
|
+
stderr=subprocess.PIPE,
|
|
550
|
+
env=environment,
|
|
551
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
self.process = subprocess.Popen(
|
|
555
|
+
[executable, *command[1:]],
|
|
556
|
+
stdin=subprocess.DEVNULL,
|
|
557
|
+
stdout=subprocess.DEVNULL,
|
|
558
|
+
stderr=subprocess.PIPE,
|
|
559
|
+
env=environment,
|
|
560
|
+
start_new_session=True,
|
|
561
|
+
)
|
|
562
|
+
_children.add(self.process)
|
|
563
|
+
self._stderr: bytes = b""
|
|
564
|
+
self._capture = True
|
|
565
|
+
self._closed = threading.Event()
|
|
566
|
+
threading.Thread(target=self._drain, name="opencode-service-stderr", daemon=True).start()
|
|
567
|
+
|
|
568
|
+
def _drain(self) -> None:
|
|
569
|
+
stream = self.process.stderr
|
|
570
|
+
assert stream is not None
|
|
571
|
+
with stream:
|
|
572
|
+
for chunk in iter(lambda: os.read(stream.fileno(), 4096), b""):
|
|
573
|
+
if self._capture:
|
|
574
|
+
self._stderr = (self._stderr + chunk)[-_STDERR_LIMIT:]
|
|
575
|
+
self.process.wait()
|
|
576
|
+
_children.discard(self.process)
|
|
577
|
+
self._closed.set()
|
|
578
|
+
|
|
579
|
+
@property
|
|
580
|
+
def pid(self) -> int:
|
|
581
|
+
return self.process.pid
|
|
582
|
+
|
|
583
|
+
def closed(self) -> bool:
|
|
584
|
+
return self._closed.is_set()
|
|
585
|
+
|
|
586
|
+
def stderr(self) -> str:
|
|
587
|
+
return self._stderr.decode("utf-8", "replace").strip()
|
|
588
|
+
|
|
589
|
+
def release(self) -> None:
|
|
590
|
+
# Keep draining so a chatty service never blocks on a full pipe, but stop keeping its output.
|
|
591
|
+
self._capture = False
|
|
592
|
+
self._stderr = b""
|
|
593
|
+
|
|
594
|
+
def failure(self) -> ServiceError | None:
|
|
595
|
+
code = self.process.returncode
|
|
596
|
+
if code is None or code == 0:
|
|
597
|
+
return None
|
|
598
|
+
if code > 0 or sys.platform == "win32":
|
|
599
|
+
return _startup_error(f"Server process exited with code {code}", self.stderr())
|
|
600
|
+
try:
|
|
601
|
+
name = signal.Signals(-code).name
|
|
602
|
+
except ValueError:
|
|
603
|
+
name = str(-code)
|
|
604
|
+
return _startup_error(f"Server process terminated by {name}", self.stderr())
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _resolve(command: str, search_path: str) -> str:
|
|
608
|
+
"""Find ``command`` on ``search_path``, the child's ``PATH``, as Node's spawn does.
|
|
609
|
+
|
|
610
|
+
``shutil.which`` on Windows also searches the current directory first, which Node's spawn in the TypeScript
|
|
611
|
+
client does not, so a planted ``opencode.exe`` there could run. A command with a directory part is used as is.
|
|
612
|
+
|
|
613
|
+
Raises:
|
|
614
|
+
FileNotFoundError: ``command`` is not on ``PATH``.
|
|
615
|
+
"""
|
|
616
|
+
if os.path.dirname(command):
|
|
617
|
+
return command
|
|
618
|
+
for directory in search_path.split(os.pathsep):
|
|
619
|
+
if directory and (found := shutil.which(os.path.join(directory, command))) is not None:
|
|
620
|
+
return found
|
|
621
|
+
# Never hand Popen a bare name: Windows CreateProcess would search the current directory for it.
|
|
622
|
+
raise FileNotFoundError(f"{command!r} was not found on PATH")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _startup_error(message: str, stderr: str) -> ServiceError:
|
|
626
|
+
return ServiceError(f"{message}\n{stderr}" if stderr else message)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@dataclass
|
|
630
|
+
class _Pool:
|
|
631
|
+
"""The startup attempts of one ``ensure`` call: at most two alive, the first failure remembered."""
|
|
632
|
+
|
|
633
|
+
timing: _Timing
|
|
634
|
+
contenders: list[_Contender] = field(default_factory=cast("Callable[[], list[_Contender]]", list))
|
|
635
|
+
failure: ServiceError | None = None
|
|
636
|
+
spawn_delay: float = 0.0
|
|
637
|
+
last_spawn: float | None = None
|
|
638
|
+
|
|
639
|
+
def __post_init__(self) -> None:
|
|
640
|
+
self.spawn_delay = self.timing.spawn_delay
|
|
641
|
+
|
|
642
|
+
def service_answered(self) -> None:
|
|
643
|
+
self.spawn_delay = self.timing.spawn_delay
|
|
644
|
+
|
|
645
|
+
def evict(self, pid: int) -> None:
|
|
646
|
+
for item in list(self.contenders):
|
|
647
|
+
if item.pid == pid or item.closed():
|
|
648
|
+
item.release()
|
|
649
|
+
self.contenders.remove(item)
|
|
650
|
+
self.failure = None
|
|
651
|
+
self.last_spawn = None
|
|
652
|
+
|
|
653
|
+
def recruit_now(self) -> None:
|
|
654
|
+
self.last_spawn = time.monotonic() - self.spawn_delay
|
|
655
|
+
|
|
656
|
+
def reap(self) -> ServiceError | None:
|
|
657
|
+
finished = [item for item in self.contenders if item.closed()]
|
|
658
|
+
if self.failure is None:
|
|
659
|
+
self.failure = next((error for item in finished if (error := item.failure()) is not None), None)
|
|
660
|
+
if any(item.process.returncode == 0 for item in finished):
|
|
661
|
+
self.spawn_delay = min(self.spawn_delay * 2, self.timing.max_spawn_delay)
|
|
662
|
+
for item in finished:
|
|
663
|
+
self.contenders.remove(item)
|
|
664
|
+
return self.failure if not self.contenders else None
|
|
665
|
+
|
|
666
|
+
def should_recruit(self, registered: bool) -> bool:
|
|
667
|
+
if self.last_spawn is None and registered:
|
|
668
|
+
self.last_spawn = time.monotonic()
|
|
669
|
+
# Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the survivors finish
|
|
670
|
+
# without recruiting replacements that could hide the error indefinitely.
|
|
671
|
+
since = float("inf") if self.last_spawn is None else time.monotonic() - self.last_spawn
|
|
672
|
+
return self.failure is None and len(self.contenders) < 2 and since >= self.spawn_delay
|
|
673
|
+
|
|
674
|
+
def add(self, contender: _Contender) -> None:
|
|
675
|
+
self.contenders.append(contender)
|
|
676
|
+
self.last_spawn = time.monotonic()
|
|
677
|
+
|
|
678
|
+
def release_all(self) -> None:
|
|
679
|
+
for contender in self.contenders:
|
|
680
|
+
contender.release()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# --- stopping processes ---
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _signal(pid: int, sig: int) -> None:
|
|
687
|
+
with contextlib.suppress(OSError):
|
|
688
|
+
os.kill(pid, sig)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _stopped(pid: int) -> bool:
|
|
692
|
+
if sys.platform == "win32":
|
|
693
|
+
return not _windows_alive(pid)
|
|
694
|
+
try:
|
|
695
|
+
os.kill(pid, 0)
|
|
696
|
+
except OSError:
|
|
697
|
+
return True
|
|
698
|
+
return False
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _windows_alive(pid: int) -> bool: # pragma: no cover - Windows only
|
|
702
|
+
import ctypes
|
|
703
|
+
|
|
704
|
+
kernel32: Any = getattr(ctypes, "windll").kernel32 # noqa: B009 - only defined on Windows
|
|
705
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
706
|
+
if not handle:
|
|
707
|
+
return False
|
|
708
|
+
try:
|
|
709
|
+
code = ctypes.c_ulong()
|
|
710
|
+
kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
711
|
+
return bool(code.value == 259) # STILL_ACTIVE
|
|
712
|
+
finally:
|
|
713
|
+
kernel32.CloseHandle(handle)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
_KILL = getattr(signal, "SIGKILL", signal.SIGTERM)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _terminate_steps(file: Path, info: _Info) -> bool:
|
|
720
|
+
current = _read(file)
|
|
721
|
+
if current is None or not _same(current, info):
|
|
722
|
+
return False
|
|
723
|
+
_signal(info.pid, signal.SIGTERM)
|
|
724
|
+
return True
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _remove_if_same(file: Path, info: _Info) -> None:
|
|
728
|
+
latest = _read(file)
|
|
729
|
+
if latest is None or not _same(latest, info):
|
|
730
|
+
return
|
|
731
|
+
with contextlib.suppress(FileNotFoundError):
|
|
732
|
+
file.unlink()
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _wait_until_stopped(pid: int, timing: _Timing) -> bool:
|
|
736
|
+
for attempt in range(timing.stop_poll_attempts + 1):
|
|
737
|
+
if _stopped(pid):
|
|
738
|
+
return True
|
|
739
|
+
if attempt < timing.stop_poll_attempts:
|
|
740
|
+
_sleep(timing.stop_poll_interval)
|
|
741
|
+
return False
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
async def _async_wait_until_stopped(pid: int, timing: _Timing) -> bool:
|
|
745
|
+
for attempt in range(timing.stop_poll_attempts + 1):
|
|
746
|
+
if _stopped(pid):
|
|
747
|
+
return True
|
|
748
|
+
if attempt < timing.stop_poll_attempts:
|
|
749
|
+
await _async_sleep(timing.stop_poll_interval)
|
|
750
|
+
return False
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _terminate(file: Path, info: _Info, timing: _Timing) -> None:
|
|
754
|
+
if not _terminate_steps(file, info):
|
|
755
|
+
return
|
|
756
|
+
# The registration can disappear or change hands before this process exits. Only the PID we signalled can
|
|
757
|
+
# tell us whether it has stopped, so escalate based on that process.
|
|
758
|
+
if not _wait_until_stopped(info.pid, timing):
|
|
759
|
+
_signal(info.pid, _KILL)
|
|
760
|
+
if not _wait_until_stopped(info.pid, timing):
|
|
761
|
+
raise ServiceError(f"Server process {info.pid} is still running")
|
|
762
|
+
_remove_if_same(file, info)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
async def _async_terminate(file: Path, info: _Info, timing: _Timing) -> None:
|
|
766
|
+
if not _terminate_steps(file, info):
|
|
767
|
+
return
|
|
768
|
+
if not await _async_wait_until_stopped(info.pid, timing):
|
|
769
|
+
_signal(info.pid, _KILL)
|
|
770
|
+
if not await _async_wait_until_stopped(info.pid, timing):
|
|
771
|
+
raise ServiceError(f"Server process {info.pid} is still running")
|
|
772
|
+
_remove_if_same(file, info)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# --- public operations ---
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def discover(file: str | os.PathLike[str] | None = None, version: Version | None = None) -> Endpoint | None:
|
|
779
|
+
"""Find a healthy, compatible local service without starting one.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
file: Registration file. Defaults to :func:`default_file`.
|
|
783
|
+
version: Required exact version, or a predicate on the service version.
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
The service endpoint, or ``None`` when no ready service with a matching version is registered.
|
|
787
|
+
"""
|
|
788
|
+
return _discovered(_registered(_file(file), _TIMING.request_timeout), version)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
async def async_discover(file: str | os.PathLike[str] | None = None, version: Version | None = None) -> Endpoint | None:
|
|
792
|
+
"""Async version of :func:`discover`."""
|
|
793
|
+
return _discovered(await _async_registered(_file(file), _TIMING.request_timeout), version)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@dataclass
|
|
797
|
+
class _Ensure:
|
|
798
|
+
"""State and decisions shared by the sync and async ``ensure`` loops."""
|
|
799
|
+
|
|
800
|
+
file: Path
|
|
801
|
+
version: Version | None
|
|
802
|
+
command: Sequence[str]
|
|
803
|
+
env: Mapping[str, str] | None
|
|
804
|
+
on_start: Callable[[EnsureReason, str | None], None] | None
|
|
805
|
+
timing: _Timing
|
|
806
|
+
pool: _Pool
|
|
807
|
+
deadline: float
|
|
808
|
+
timeouts: tuple[_Info, int] | None = None
|
|
809
|
+
announced: bool = False
|
|
810
|
+
|
|
811
|
+
def announce(self, reason: EnsureReason, previous_version: str | None = None) -> None:
|
|
812
|
+
if self.announced:
|
|
813
|
+
return
|
|
814
|
+
self.announced = True
|
|
815
|
+
if self.on_start is not None:
|
|
816
|
+
self.on_start(reason, previous_version)
|
|
817
|
+
|
|
818
|
+
def check_deadline(self) -> None:
|
|
819
|
+
if time.monotonic() >= self.deadline:
|
|
820
|
+
raise self.pool.failure or ServiceError("Timed out waiting for the background service to start")
|
|
821
|
+
|
|
822
|
+
def unresponsive(self, registration: _Registration) -> _Info | None:
|
|
823
|
+
"""The registered owner to replace after three consecutive timed-out probes."""
|
|
824
|
+
info = registration.info
|
|
825
|
+
if not registration.timed_out or info is None:
|
|
826
|
+
self.timeouts = None
|
|
827
|
+
return None
|
|
828
|
+
count = self.timeouts[1] + 1 if self.timeouts is not None and _same(self.timeouts[0], info) else 1
|
|
829
|
+
self.timeouts = (info, count)
|
|
830
|
+
if count < 3:
|
|
831
|
+
return None
|
|
832
|
+
self.announce("missing")
|
|
833
|
+
log.warning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
|
834
|
+
_pty_clear(self.file)
|
|
835
|
+
return info
|
|
836
|
+
|
|
837
|
+
def recovered(self, info: _Info) -> None:
|
|
838
|
+
self.pool.evict(info.pid)
|
|
839
|
+
self.pool.recruit_now()
|
|
840
|
+
self.timeouts = None
|
|
841
|
+
|
|
842
|
+
def spawn(self) -> None:
|
|
843
|
+
if not self.command:
|
|
844
|
+
raise ServiceError("Missing service command")
|
|
845
|
+
try:
|
|
846
|
+
contender = _Contender(self.command, _pty_environment(self.file, self.env))
|
|
847
|
+
except OSError as cause:
|
|
848
|
+
raise ServiceError("Failed to start server") from cause
|
|
849
|
+
self.pool.add(contender)
|
|
850
|
+
|
|
851
|
+
def no_answer(self, registration: _Registration) -> None:
|
|
852
|
+
failed = self.pool.reap()
|
|
853
|
+
if failed is not None:
|
|
854
|
+
raise failed
|
|
855
|
+
if self.pool.should_recruit(registration.info is not None):
|
|
856
|
+
self.announce("missing")
|
|
857
|
+
self.spawn()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _ensure_state(
|
|
861
|
+
file: str | os.PathLike[str] | None,
|
|
862
|
+
version: Version | None,
|
|
863
|
+
command: Sequence[str] | None,
|
|
864
|
+
env: Mapping[str, str] | None,
|
|
865
|
+
on_start: Callable[[EnsureReason, str | None], None] | None,
|
|
866
|
+
) -> _Ensure:
|
|
867
|
+
timing = _TIMING
|
|
868
|
+
return _Ensure(
|
|
869
|
+
file=_file(file),
|
|
870
|
+
version=version,
|
|
871
|
+
command=command if command is not None else ("opencode", "serve", "--service"),
|
|
872
|
+
env=env,
|
|
873
|
+
on_start=on_start,
|
|
874
|
+
timing=timing,
|
|
875
|
+
pool=_Pool(timing),
|
|
876
|
+
deadline=time.monotonic() + timing.timeout,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def ensure(
|
|
881
|
+
file: str | os.PathLike[str] | None = None,
|
|
882
|
+
version: Version | None = None,
|
|
883
|
+
*,
|
|
884
|
+
command: Sequence[str] | None = None,
|
|
885
|
+
env: Mapping[str, str] | None = None,
|
|
886
|
+
on_start: Callable[[EnsureReason, str | None], None] | None = None,
|
|
887
|
+
) -> Endpoint:
|
|
888
|
+
"""Return a healthy local service, starting ``opencode serve --service`` when none is running.
|
|
889
|
+
|
|
890
|
+
A registered service whose version does not satisfy ``version`` is stopped and replaced; its persistent
|
|
891
|
+
terminals are handed off to the replacement when it is ready. Several processes may call this at once: each
|
|
892
|
+
starts at most two attempts, the service's own lock picks one winner, and the losers exit cleanly. Gives up
|
|
893
|
+
after 120 seconds.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
file: Registration file. Defaults to :func:`default_file`.
|
|
897
|
+
version: Required exact version, or a predicate on the service version.
|
|
898
|
+
command: Service command and arguments. Defaults to ``opencode serve --service``.
|
|
899
|
+
env: Variables added to the inherited environment of a started service.
|
|
900
|
+
on_start: Called once, before the first process is started, with the reason and, for
|
|
901
|
+
``"version-mismatch"``, the version being replaced.
|
|
902
|
+
|
|
903
|
+
Raises:
|
|
904
|
+
ServiceError: The service failed to start, uses an incompatible protocol, or did not answer in time.
|
|
905
|
+
"""
|
|
906
|
+
state = _ensure_state(file, version, command, env, on_start)
|
|
907
|
+
try:
|
|
908
|
+
while True:
|
|
909
|
+
state.check_deadline()
|
|
910
|
+
registration = _registered(state.file, state.timing.request_timeout)
|
|
911
|
+
stale = state.unresponsive(registration)
|
|
912
|
+
if stale is not None:
|
|
913
|
+
_terminate(state.file, stale, state.timing)
|
|
914
|
+
state.recovered(stale)
|
|
915
|
+
|
|
916
|
+
service = registration.service
|
|
917
|
+
if service is not None:
|
|
918
|
+
state.pool.service_answered()
|
|
919
|
+
decision = _decide(service, state.version)
|
|
920
|
+
if decision.tag == "fail":
|
|
921
|
+
raise ServiceError(decision.error)
|
|
922
|
+
if decision.tag == "reuse":
|
|
923
|
+
_pty_complete(state.file, service.info)
|
|
924
|
+
return service.endpoint
|
|
925
|
+
if decision.tag == "replace":
|
|
926
|
+
state.announce("version-mismatch", service.version)
|
|
927
|
+
if service.state != "ready":
|
|
928
|
+
log.warning("Background service is not ready; replacement cannot preserve persistent terminals")
|
|
929
|
+
with contextlib.suppress(Exception):
|
|
930
|
+
stop(state.file, pty=decision.pty)
|
|
931
|
+
state.pool.evict(service.info.pid)
|
|
932
|
+
else:
|
|
933
|
+
state.no_answer(registration)
|
|
934
|
+
_sleep(state.timing.poll_interval)
|
|
935
|
+
finally:
|
|
936
|
+
state.pool.release_all()
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
async def async_ensure(
|
|
940
|
+
file: str | os.PathLike[str] | None = None,
|
|
941
|
+
version: Version | None = None,
|
|
942
|
+
*,
|
|
943
|
+
command: Sequence[str] | None = None,
|
|
944
|
+
env: Mapping[str, str] | None = None,
|
|
945
|
+
on_start: Callable[[EnsureReason, str | None], None] | None = None,
|
|
946
|
+
) -> Endpoint:
|
|
947
|
+
"""Async version of :func:`ensure`."""
|
|
948
|
+
state = _ensure_state(file, version, command, env, on_start)
|
|
949
|
+
try:
|
|
950
|
+
while True:
|
|
951
|
+
state.check_deadline()
|
|
952
|
+
registration = await _async_registered(state.file, state.timing.request_timeout)
|
|
953
|
+
stale = state.unresponsive(registration)
|
|
954
|
+
if stale is not None:
|
|
955
|
+
await _async_terminate(state.file, stale, state.timing)
|
|
956
|
+
state.recovered(stale)
|
|
957
|
+
|
|
958
|
+
service = registration.service
|
|
959
|
+
if service is not None:
|
|
960
|
+
state.pool.service_answered()
|
|
961
|
+
decision = _decide(service, state.version)
|
|
962
|
+
if decision.tag == "fail":
|
|
963
|
+
raise ServiceError(decision.error)
|
|
964
|
+
if decision.tag == "reuse":
|
|
965
|
+
_pty_complete(state.file, service.info)
|
|
966
|
+
return service.endpoint
|
|
967
|
+
if decision.tag == "replace":
|
|
968
|
+
state.announce("version-mismatch", service.version)
|
|
969
|
+
if service.state != "ready":
|
|
970
|
+
log.warning("Background service is not ready; replacement cannot preserve persistent terminals")
|
|
971
|
+
with contextlib.suppress(Exception):
|
|
972
|
+
await async_stop(state.file, pty=decision.pty)
|
|
973
|
+
state.pool.evict(service.info.pid)
|
|
974
|
+
else:
|
|
975
|
+
state.no_answer(registration)
|
|
976
|
+
await _async_sleep(state.timing.poll_interval)
|
|
977
|
+
finally:
|
|
978
|
+
state.pool.release_all()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def stop(file: str | os.PathLike[str] | None = None, *, pty: PtyMode = "clear") -> None:
|
|
982
|
+
"""Stop the registered local service and remove its registration.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
file: Registration file. Defaults to :func:`default_file`.
|
|
986
|
+
pty: ``"clear"`` shuts persistent terminals down with the service. ``"handoff"`` first asks the service
|
|
987
|
+
for a handoff ticket that the next service started by :func:`ensure` adopts. The handoff is best
|
|
988
|
+
effort and never keeps the old service running.
|
|
989
|
+
|
|
990
|
+
Raises:
|
|
991
|
+
ServiceError: The process was still running after ``SIGTERM`` and ``SIGKILL``.
|
|
992
|
+
"""
|
|
993
|
+
path = _file(file)
|
|
994
|
+
info = _read(path)
|
|
995
|
+
try:
|
|
996
|
+
if pty == "handoff" and info is not None:
|
|
997
|
+
_pty_prepare(path, info, _TIMING.request_timeout)
|
|
998
|
+
else:
|
|
999
|
+
_pty_clear(path)
|
|
1000
|
+
except Exception as cause:
|
|
1001
|
+
log.warning("Failed to prepare persistent terminals for replacement: %s", cause)
|
|
1002
|
+
if info is not None:
|
|
1003
|
+
_terminate(path, info, _TIMING)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
async def async_stop(file: str | os.PathLike[str] | None = None, *, pty: PtyMode = "clear") -> None:
|
|
1007
|
+
"""Async version of :func:`stop`."""
|
|
1008
|
+
path = _file(file)
|
|
1009
|
+
info = _read(path)
|
|
1010
|
+
try:
|
|
1011
|
+
if pty == "handoff" and info is not None:
|
|
1012
|
+
await _async_pty_prepare(path, info, _TIMING.request_timeout)
|
|
1013
|
+
else:
|
|
1014
|
+
_pty_clear(path)
|
|
1015
|
+
except Exception as cause:
|
|
1016
|
+
log.warning("Failed to prepare persistent terminals for replacement: %s", cause)
|
|
1017
|
+
if info is not None:
|
|
1018
|
+
await _async_terminate(path, info, _TIMING)
|
opencode/service.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Find, start and stop the local OpenCode background service.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from opencode import Opencode, service
|
|
6
|
+
|
|
7
|
+
endpoint = service.ensure() # or service.discover(), which never starts one
|
|
8
|
+
print(endpoint.url)
|
|
9
|
+
|
|
10
|
+
with Opencode.from_service() as client:
|
|
11
|
+
print(client.server.info().version)
|
|
12
|
+
|
|
13
|
+
See :mod:`opencode._service` for how this follows the TypeScript client.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._service import (
|
|
19
|
+
BasicAuth,
|
|
20
|
+
Endpoint,
|
|
21
|
+
EnsureReason,
|
|
22
|
+
PtyMode,
|
|
23
|
+
ServiceError,
|
|
24
|
+
Version,
|
|
25
|
+
async_discover,
|
|
26
|
+
async_ensure,
|
|
27
|
+
async_stop,
|
|
28
|
+
default_file,
|
|
29
|
+
discover,
|
|
30
|
+
ensure,
|
|
31
|
+
headers,
|
|
32
|
+
stop,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BasicAuth",
|
|
37
|
+
"Endpoint",
|
|
38
|
+
"EnsureReason",
|
|
39
|
+
"PtyMode",
|
|
40
|
+
"ServiceError",
|
|
41
|
+
"Version",
|
|
42
|
+
"async_discover",
|
|
43
|
+
"async_ensure",
|
|
44
|
+
"async_stop",
|
|
45
|
+
"default_file",
|
|
46
|
+
"discover",
|
|
47
|
+
"ensure",
|
|
48
|
+
"headers",
|
|
49
|
+
"stop",
|
|
50
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencode-python-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Typed Python client for the OpenCode 2.x server API
|
|
5
5
|
Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
|
|
6
6
|
Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
|
|
@@ -419,6 +419,27 @@ with server:
|
|
|
419
419
|
The server runs in `--stdio` mode, so it also exits when the Python process dies. `AsyncOpencodeServer` is the
|
|
420
420
|
async equivalent.
|
|
421
421
|
|
|
422
|
+
### The background service
|
|
423
|
+
|
|
424
|
+
OpenCode can also run one shared background server per user (`opencode serve --service`), which registers itself in
|
|
425
|
+
`$XDG_STATE_HOME/opencode/service.json`. `Opencode.from_service()` connects to it, starting it first if it is not
|
|
426
|
+
running:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
from opencode import Opencode, service
|
|
430
|
+
|
|
431
|
+
with Opencode.from_service(version="2.1.2") as client: # replaces a service running another version
|
|
432
|
+
print(client.server.info().version)
|
|
433
|
+
|
|
434
|
+
endpoint = service.discover() # an Endpoint, or None; never starts anything
|
|
435
|
+
service.stop(pty="handoff") # hand persistent terminals to the next service instead of closing them
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`service.ensure()` returns the `Endpoint` (URL and credentials) without creating a client, and `service.headers()`
|
|
439
|
+
gives its `Authorization` header for other HTTP clients. Concurrent callers are safe: they race to start the service
|
|
440
|
+
and all get the one that won. `await AsyncOpencode.from_service()`, `service.async_discover()`,
|
|
441
|
+
`service.async_ensure()` and `service.async_stop()` are the async equivalents.
|
|
442
|
+
|
|
422
443
|
## Advanced
|
|
423
444
|
|
|
424
445
|
```python
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
|
|
2
|
-
opencode/__init__.py,sha256=
|
|
2
|
+
opencode/__init__.py,sha256=ZknWy2NBxHe4C6prS9gW56FBu2UoV-OLcMsMY4hnEKI,5759
|
|
3
3
|
opencode/_base_client.py,sha256=oA7Yah1WYyw16WrF_x76T1uZE5Arwt4jQMK8gyrL_4Y,21008
|
|
4
|
-
opencode/_client.py,sha256=
|
|
4
|
+
opencode/_client.py,sha256=nUkvwHuKRpISQgy4-d9c62nq9zMtzENsuqbQE4KKwAw,15402
|
|
5
5
|
opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
|
|
6
6
|
opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
|
|
7
7
|
opencode/_events.py,sha256=Pg6zgPT37GHRITM5HvuxC6hmdZVrd2msyfsFQPoOAcQ,30179
|
|
@@ -13,11 +13,13 @@ opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
|
|
|
13
13
|
opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
|
|
14
14
|
opencode/_rpc.py,sha256=WrPQ_hRhsneXLvYj7WwEADC6_OCMRE5muuWVoysKPok,17708
|
|
15
15
|
opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
|
|
16
|
+
opencode/_service.py,sha256=LtkGdZBbRH6I7F6PYnzMNNOqXudze_Uy3HO_ghnCPf4,36622
|
|
16
17
|
opencode/_streaming.py,sha256=TfnfZaNucjLrpfOB9m1i_sRtjYoACztTflavrhwvnDE,8861
|
|
17
18
|
opencode/_types.py,sha256=Z8bYxzpZhPRtisvPUv9R5g4oBlC23iXR70fFzReTOc0,2194
|
|
18
19
|
opencode/_unions.py,sha256=dW0dTmP2QK2lL6RhyK2YsjD2yNbO8Ah6pk-i5ySW3lo,1518
|
|
19
20
|
opencode/_version.py,sha256=zNErHu8z_g6ScOeYcO8QAiktg036FVPaUiIIZefeoLU,259
|
|
20
21
|
opencode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
opencode/service.py,sha256=kFjQ4mjV30-VXx5B22ZPUs0V0e__fyTZ2Yj2VAoUrbU,921
|
|
21
23
|
opencode/testing.py,sha256=_EGoh9SwC5WCwbXjm5AtbyAzB4zyO5xX2N-skMLHzR4,21069
|
|
22
24
|
opencode/lib/__init__.py,sha256=mWBVu6RUOqMNJHHZPLq2nkks7QdsWJgLPN44BmfDv9s,120
|
|
23
25
|
opencode/lib/_ready.py,sha256=dhX-n6DDjRWsYJ95paxt1i5WnlU_KsFsTqgkJEt01Q4,3286
|
|
@@ -56,7 +58,7 @@ opencode/resources/websearch.py,sha256=hqXcTPq-lGnqB34VMcFL59KbS2vvfwd-JntgUozKA
|
|
|
56
58
|
opencode/resources/worktree.py,sha256=rRDysFG4zncjlIgjkwxT2UKkUgLXMr2DxeEfg5j2YrE,11016
|
|
57
59
|
opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
|
|
58
60
|
opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
|
|
59
|
-
opencode_python_sdk-0.2.
|
|
60
|
-
opencode_python_sdk-0.2.
|
|
61
|
-
opencode_python_sdk-0.2.
|
|
62
|
-
opencode_python_sdk-0.2.
|
|
61
|
+
opencode_python_sdk-0.2.2.dist-info/METADATA,sha256=d8EuoMpm4YOLAqDLpupbnPDrHbOJEZXln6pLYfELqrs,19023
|
|
62
|
+
opencode_python_sdk-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
63
|
+
opencode_python_sdk-0.2.2.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
|
|
64
|
+
opencode_python_sdk-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
{opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|