Skip to content

Add local background service helpers (opencode.service, from_service) - #10

Merged
shrijayan merged 3 commits into
mainfrom
claude/service-helpers
Oct 22, 2026
Merged

shrijayan merged 3 commits into
mainfrom
claude/service-helpers

Conversation

@shrijayan

@shrijayan shrijayan commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Ports the OpenCode TS client's local-service helpers (packages/client/src/{service,service-probe,service-contender,service-timing,service-version,promise/service,pty-handoff}.ts at v2.1.2) to src/opencode/_service.py, exposed as the opencode.service module.

Not for self-merge: merges to main release to PyPI, so the owner merges.

API

  • service.discover(file=None, version=None) -> Endpoint | None, service.ensure(file, version, *, command, env, on_start) -> Endpoint, service.stop(file, *, pty="clear"|"handoff"), service.headers(endpoint), service.default_file(); async_discover / async_ensure / async_stop.
  • Endpoint(url, auth: BasicAuth | None) frozen dataclass, ServiceError(OpencodeError); Endpoint and ServiceError are also top-level exports.
  • Opencode.from_service(...) and await AsyncOpencode.from_service(...) (start=False uses discover and raises ServiceError if nothing matches).

Shape: module functions, not a Service class — the operations share no state, so a class would only be a namespace; from opencode import service; service.ensure() reads the same as TS Service.ensure(). Async twins use async_ prefixes and a real async implementation (httpx.AsyncClient, anyio sleeps) sharing all decision logic with the sync one.

Matched TS behavior

  • Registration file at $XDG_STATE_HOME/opencode/service.json (default ~/.local/state), probe GET /api/info with basic auth opencode:<password>; 404 → incompatible, pid/version mismatch → not this service, 2xx ready / 500 failed / other waiting; timeouts tracked separately.
  • ensure loop: reuse / wait / replace on version mismatch (handoff if ready, else clear) / fail; on_start called once with missing or version-mismatch + previous version; three consecutive timeouts from the same registration → clear PTY sidecar, terminate, evict, recruit immediately.
  • Contender pool: at most 2 attempts, first failure remembered (exit code / signal + last 8 KiB of stderr), spawn delay 5 s doubling to 30 s when an attempt exits 0, reset when a service answers; no recruiting after a failure.
  • Timings: poll 25 ms, request timeout 2 s, overall 120 s, stop poll 50 ms × 100, SIGTERM then SIGKILL, registration removed only if it still names the same instance.
  • PTY handoff sidecar (service.json.pty-handoff): prepare/publish (0600, atomic rename, 30 s TTL for null handoff), shutdown on failed handoff, OPENCODE_PTY_HANDOFF passed to new contenders when fresh and matching, cleared when a different owner is reused.
  • Detached child (start_new_session / new process group on Windows), stdin/stdout ignored.

Deliberate differences

  • A registration file missing a string url or integer pid is treated as absent (TS casts unvalidated JSON and then fails later on it).
  • Spawn failures (e.g. command not found) raise ServiceError("Failed to start server") immediately instead of on the next poll.
  • console.warn → logging.getLogger("opencode").warning.
  • PTY handoff calls use raw httpx instead of the generated client, to keep the TS response validation exactly.
  • Test timing is overridden via the private _service._TIMING (TS uses a WeakMap) — not part of the public signature in either.
  • Docs: docs/getting-started/service.md guide and docs/reference/service.md reference (rebased onto the MkDocs site; mkdocs build --strict and the docs-coverage test pass).

Tests

  • tests/test_service.py: temp registration files, respx-mocked probes, and tests/fake_service.py (a real child process that registers, serves /api/info and PTY endpoints, or fails / loses the race).
  • tests/test_service_live.py (live): real opencode serve --service with temporary XDG dirs and a free managed port written to XDG_CONFIG_HOME/opencode/service.json, so a developer's real service is never touched.

Security note

Like the TS client, the probe sends the registered password to the registered URL and trusts the self-reported pid as identity. A process that can write $XDG_STATE_HOME/opencode/service.json can therefore receive the password. Kept identical to TS here; possible hardening (check the file is owned by the user and not group/world-writable) is a follow-up best done upstream too.

Comment thread tests/fake_service.py Fixed
@shrijayan
shrijayan force-pushed the claude/service-helpers branch 2 times, most recently from addab3e to b17fce7 Compare October 22, 2026 10:28
opencode.service gets discover, ensure, stop and headers, with async twins,
matching packages/client/src/promise/service.ts at OpenCode v2.1.2: the
registration-file contract, health classification, version replacement,
contender race handling and timing, and persistent terminal handoff.
Opencode.from_service and AsyncOpencode.from_service connect to it.
Includes unit tests with a fake service process, a live test in temporary
XDG dirs, a README section and docs pages.
BasicAuth and the registration record hide their password from repr, so
endpoints can be logged safely. The service command is resolved from PATH
entries only: shutil.which on Windows also tries the current directory,
which Node's spawn does not.
Falling back to the bare name let Windows CreateProcess search the current
directory. Look the command up on the child's PATH, as Node's spawn does,
and raise (surfaced as "Failed to start server") when it is missing.
@shrijayan
shrijayan force-pushed the claude/service-helpers branch from c2073b5 to c0f7fc5 Compare October 22, 2026 11:07
@shrijayan
shrijayan enabled auto-merge (rebase) October 22, 2026 11:07
@shrijayan
shrijayan merged commit 08f8998 into main Oct 22, 2026
17 checks passed
@shrijayan
shrijayan deleted the claude/service-helpers branch October 22, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants