Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,27 @@ with server:
The server runs in `--stdio` mode, so it also exits when the Python process dies. `AsyncOpencodeServer` is the
async equivalent.

### The background service

OpenCode can also run one shared background server per user (`opencode serve --service`), which registers itself in
`$XDG_STATE_HOME/opencode/service.json`. `Opencode.from_service()` connects to it, starting it first if it is not
running:

```python
from opencode import Opencode, service

with Opencode.from_service(version="2.1.2") as client: # replaces a service running another version
print(client.server.info().version)

endpoint = service.discover() # an Endpoint, or None; never starts anything
service.stop(pty="handoff") # hand persistent terminals to the next service instead of closing them
```

`service.ensure()` returns the `Endpoint` (URL and credentials) without creating a client, and `service.headers()`
gives its `Authorization` header for other HTTP clients. Concurrent callers are safe: they race to start the service
and all get the one that won. `await AsyncOpencode.from_service()`, `service.async_discover()`,
`service.async_ensure()` and `service.async_stop()` are the async equivalents.

## Advanced

```python
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ as well, so that `opencode` is on `PATH`.

- [Connect to a server](connect.md) that is already running.
- [Run a server from Python](server.md) for scripts, tests and embedded use.
- [Use the background service](service.md) to share one `opencode serve --service` between programs.
39 changes: 39 additions & 0 deletions docs/getting-started/service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Use the background service

OpenCode can run one shared background server per user, `opencode serve --service`. It registers itself in
`$XDG_STATE_HOME/opencode/service.json` (default `~/.local/state/opencode/service.json`) with its URL, PID, version
and password. `Opencode.from_service()` connects to it, starting it first if it is not running:

```python
from opencode import Opencode

with Opencode.from_service(version="2.1.2") as client: # (1)!
print(client.server.info().version)
```

1. Optional. A service running another version is stopped and replaced. `version` can also be a predicate,
such as `lambda v: v.startswith("2.")`.

Pass `start=False` to only connect to a running service; it raises `ServiceError` when there is none.

## Without a client

```python
from opencode import service

endpoint = service.discover() # an Endpoint, or None; never starts anything
endpoint = service.ensure() # starts the service when needed
service.headers(endpoint) # {"authorization": "Basic ..."} for other HTTP clients
service.stop(pty="handoff") # hand persistent terminals to the next service
```

Several processes may call `ensure()` at once: each starts at most two attempts, the service's own lock picks a
winner, and every caller gets the same service. It gives up after 120 seconds with a `ServiceError` that includes
the end of the failed process's stderr.

## Async

`await AsyncOpencode.from_service()`, `service.async_discover()`, `service.async_ensure()` and
`service.async_stop()` take the same arguments.

These helpers follow the TypeScript client's `Service` from `@opencode-ai/client`.
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Generated from the docstrings in `src/opencode`.
| --- | --- |
| [Clients](clients.md) | `Opencode`, `AsyncOpencode` |
| [Server launcher](server.md) | `OpencodeServer`, `AsyncOpencodeServer` |
| [Background service](service.md) | `opencode.service`, `Endpoint`, `ServiceError` |
| [Resources](resources.md) | Every `client.<group>` and its methods |
| [Pagination and streams](pagination.md) | `SyncCursorPage`, `AsyncPaginator`, `Stream`, `AsyncStream` |
| [Shared events and RPC](events.md) | `EventHub`, `Subscription`, `RpcDefinition`, `RpcClient` |
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Background service

The `opencode.service` module, and the `from_service` constructors on
[`Opencode`](clients.md) and [`AsyncOpencode`](clients.md).

::: opencode.service
options:
show_root_heading: false
members: [discover, ensure, stop, headers, default_file, async_discover, async_ensure, async_stop]

::: opencode.Endpoint

::: opencode.service.BasicAuth

::: opencode.ServiceError
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ nav:
- getting-started/index.md
- Connect to a server: getting-started/connect.md
- Run a server from Python: getting-started/server.md
- Use the background service: getting-started/service.md
- Guides:
- guides/index.md
- Sessions and prompts: guides/sessions.md
Expand All @@ -152,6 +153,7 @@ nav:
- reference/index.md
- Clients: reference/clients.md
- Server launcher: reference/server.md
- Background service: reference/service.md
- Resources: reference/resources.md
- Pagination and streams: reference/pagination.md
- Shared events and RPC: reference/events.md
Expand Down
5 changes: 5 additions & 0 deletions src/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from typing import TYPE_CHECKING, Any

from . import service
from ._client import AsyncOpencode, Opencode

# <generated:error-imports>
Expand Down Expand Up @@ -78,6 +79,7 @@
from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
from ._service import Endpoint, ServiceError
from ._streaming import AsyncStream, Stream
from ._types import NOT_GIVEN, NotGiven
from ._version import __version__
Expand Down Expand Up @@ -136,6 +138,7 @@
"BaseModel",
"ConflictError",
"ContentTooLargeError",
"Endpoint",
"InternalServerError",
"NotFoundError",
"NotGiven",
Expand All @@ -148,11 +151,13 @@
"PtyConnectionError",
"RateLimitError",
"ServerStartError",
"ServiceError",
"ServiceUnavailableError",
"Stream",
"SyncCursorPage",
"UnprocessableEntityError",
"__version__",
"service",
"types",
# Shared event stream and typed plugin RPC
"SUBSCRIBER_CAPACITY",
Expand Down
108 changes: 107 additions & 1 deletion src/opencode/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@
from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
from ._events import AsyncEventHub, EventHub
from ._rpc import AsyncTypedRpcResource, TypedRpcResource
from ._service import (
EnsureReason,
ServiceError,
Version,
async_discover,
async_ensure,
discover as discover_service,
ensure as ensure_service,
)
from ._types import NOT_GIVEN, NotGiven, Timeout
from .resources import AsyncResources, SyncResources

if TYPE_CHECKING:
import os
from collections.abc import Iterable, Mapping
from collections.abc import Callable, Iterable, Mapping, Sequence

import httpx

Expand Down Expand Up @@ -82,6 +91,67 @@ def __init__(
transport=transport,
)

@classmethod
def from_service(
cls,
*,
start: bool = True,
file: str | os.PathLike[str] | None = None,
version: Version | None = None,
command: Sequence[str] | None = None,
env: Mapping[str, str] | None = None,
on_start: Callable[[EnsureReason, str | None], None] | None = None,
directory: str | os.PathLike[str] | None = None,
timeout: Timeout = DEFAULT_TIMEOUT,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
) -> Self:
"""Connect to the local background service, starting ``opencode serve --service`` if needed.

Uses :func:`opencode.service.ensure`, or :func:`opencode.service.discover` when ``start`` is false, and
sends the service's password with every request.

Args:
start: Start or replace the service when no matching one is running. When false, raise instead.
file: Registration file. Defaults to ``$XDG_STATE_HOME/opencode/service.json``.
version: Required exact version, or a predicate on the service version.
command: Service command and arguments, used when starting it.
env: Variables added to the environment of a started service.
on_start: Called once before a service process is started.
directory: Default project directory, as in the constructor.
timeout: Default request timeout, as in the constructor.
max_retries: Retries for failed requests, as in the constructor.
default_headers: Headers sent with every request.
default_query: Query parameters sent with every request.

Raises:
ServiceError: No matching service is running and ``start`` is false, or it could not be started.

Example::

with Opencode.from_service() as client:
print(client.server.info().version)
"""
if start:
endpoint = ensure_service(file, version, command=command, env=env, on_start=on_start)
else:
found = discover_service(file, version)
if found is None:
raise ServiceError("No matching OpenCode background service is running")
endpoint = found
return cls(
endpoint.url,
# An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
password="" if endpoint.auth is None else endpoint.auth.password,
username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
directory=directory,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
)

@cached_property
def events(self) -> EventHub:
"""The shared event stream: one connection for every subscriber. See :class:`opencode.EventHub`."""
Expand Down Expand Up @@ -189,6 +259,42 @@ def __init__(
transport=transport,
)

@classmethod
async def from_service(
cls,
*,
start: bool = True,
file: str | os.PathLike[str] | None = None,
version: Version | None = None,
command: Sequence[str] | None = None,
env: Mapping[str, str] | None = None,
on_start: Callable[[EnsureReason, str | None], None] | None = None,
directory: str | os.PathLike[str] | None = None,
timeout: Timeout = DEFAULT_TIMEOUT,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
) -> Self:
"""Async version of :meth:`Opencode.from_service`. Await it: ``client = await AsyncOpencode.from_service()``."""
if start:
endpoint = await async_ensure(file, version, command=command, env=env, on_start=on_start)
else:
found = await async_discover(file, version)
if found is None:
raise ServiceError("No matching OpenCode background service is running")
endpoint = found
return cls(
endpoint.url,
# An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
password="" if endpoint.auth is None else endpoint.auth.password,
username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
directory=directory,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
)

@cached_property
def events(self) -> AsyncEventHub:
"""The shared event stream: one connection for every subscriber. See :class:`opencode.AsyncEventHub`."""
Expand Down
Loading
Loading