opencode-python-sdk 0.2.0__py3-none-any.whl → 0.2.3__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.
Files changed (45) hide show
  1. opencode/__init__.py +14 -0
  2. opencode/_aiohttp.py +44 -0
  3. opencode/_base_client.py +237 -11
  4. opencode/_client.py +115 -3
  5. opencode/_logs.py +29 -0
  6. opencode/_pagination.py +14 -0
  7. opencode/_response.py +316 -0
  8. opencode/_service.py +1018 -0
  9. opencode/resources/agent.py +63 -0
  10. opencode/resources/command.py +59 -0
  11. opencode/resources/config.py +93 -20
  12. opencode/resources/credential.py +149 -56
  13. opencode/resources/debug.py +156 -20
  14. opencode/resources/event.py +59 -0
  15. opencode/resources/experimental.py +210 -36
  16. opencode/resources/file.py +71 -0
  17. opencode/resources/form.py +59 -0
  18. opencode/resources/generate.py +59 -0
  19. opencode/resources/integration.py +538 -128
  20. opencode/resources/location.py +87 -18
  21. opencode/resources/mcp.py +252 -82
  22. opencode/resources/message.py +59 -0
  23. opencode/resources/migration.py +126 -0
  24. opencode/resources/model.py +63 -0
  25. opencode/resources/permission.py +272 -38
  26. opencode/resources/plugin.py +95 -22
  27. opencode/resources/project.py +63 -0
  28. opencode/resources/provider.py +63 -0
  29. opencode/resources/pty.py +172 -20
  30. opencode/resources/reference.py +59 -0
  31. opencode/resources/rpc.py +59 -0
  32. opencode/resources/server.py +67 -0
  33. opencode/resources/session.py +1159 -405
  34. opencode/resources/shell.py +101 -20
  35. opencode/resources/skill.py +59 -0
  36. opencode/resources/vcs.py +172 -20
  37. opencode/resources/voice.py +59 -0
  38. opencode/resources/websearch.py +63 -0
  39. opencode/resources/worktree.py +123 -40
  40. opencode/service.py +50 -0
  41. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/METADATA +111 -2
  42. opencode_python_sdk-0.2.3.dist-info/RECORD +67 -0
  43. opencode_python_sdk-0.2.0.dist-info/RECORD +0 -62
  44. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/WHEEL +0 -0
  45. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/licenses/LICENSE +0 -0
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)