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 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.0
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=pOp2V1rsw6AXX5sfjq6BHcntuVzq2ZOC3-O9x-wfWFo,5641
2
+ opencode/__init__.py,sha256=ZknWy2NBxHe4C6prS9gW56FBu2UoV-OLcMsMY4hnEKI,5759
3
3
  opencode/_base_client.py,sha256=oA7Yah1WYyw16WrF_x76T1uZE5Arwt4jQMK8gyrL_4Y,21008
4
- opencode/_client.py,sha256=cVJmxAZR9TGvQ9zb4glmxcLHQcbyL3kFI56yYPAzQhA,10722
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.0.dist-info/METADATA,sha256=Pu1aWz-xygADujt8yWZxXiGeMDR6iLJPNKcTaNU1rdI,17972
60
- opencode_python_sdk-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
61
- opencode_python_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
62
- opencode_python_sdk-0.2.0.dist-info/RECORD,,
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,,