optio-opencode 0.1.0__tar.gz

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 (30) hide show
  1. optio_opencode-0.1.0/PKG-INFO +84 -0
  2. optio_opencode-0.1.0/README.md +55 -0
  3. optio_opencode-0.1.0/pyproject.toml +50 -0
  4. optio_opencode-0.1.0/setup.cfg +4 -0
  5. optio_opencode-0.1.0/src/optio_opencode/__init__.py +38 -0
  6. optio_opencode-0.1.0/src/optio_opencode/host_actions.py +432 -0
  7. optio_opencode-0.1.0/src/optio_opencode/prompt.py +153 -0
  8. optio_opencode-0.1.0/src/optio_opencode/session.py +552 -0
  9. optio_opencode-0.1.0/src/optio_opencode/snapshots.py +101 -0
  10. optio_opencode-0.1.0/src/optio_opencode/types.py +55 -0
  11. optio_opencode-0.1.0/src/optio_opencode.egg-info/PKG-INFO +84 -0
  12. optio_opencode-0.1.0/src/optio_opencode.egg-info/SOURCES.txt +28 -0
  13. optio_opencode-0.1.0/src/optio_opencode.egg-info/dependency_links.txt +1 -0
  14. optio_opencode-0.1.0/src/optio_opencode.egg-info/requires.txt +7 -0
  15. optio_opencode-0.1.0/src/optio_opencode.egg-info/top_level.txt +1 -0
  16. optio_opencode-0.1.0/tests/test_host_local.py +119 -0
  17. optio_opencode-0.1.0/tests/test_host_primitives_local.py +198 -0
  18. optio_opencode-0.1.0/tests/test_host_primitives_remote.py +242 -0
  19. optio_opencode-0.1.0/tests/test_host_remote_resume.py +222 -0
  20. optio_opencode-0.1.0/tests/test_host_resume.py +86 -0
  21. optio_opencode-0.1.0/tests/test_prompt.py +99 -0
  22. optio_opencode-0.1.0/tests/test_sanity.py +38 -0
  23. optio_opencode-0.1.0/tests/test_session_blob_hooks.py +139 -0
  24. optio_opencode-0.1.0/tests/test_session_hooks.py +281 -0
  25. optio_opencode-0.1.0/tests/test_session_local.py +428 -0
  26. optio_opencode-0.1.0/tests/test_session_remote.py +147 -0
  27. optio_opencode-0.1.0/tests/test_session_resume.py +189 -0
  28. optio_opencode-0.1.0/tests/test_smart_install.py +480 -0
  29. optio_opencode-0.1.0/tests/test_snapshots.py +87 -0
  30. optio_opencode-0.1.0/tests/test_types.py +97 -0
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: optio-opencode
3
+ Version: 0.1.0
4
+ Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
+ Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/deai-network/optio
8
+ Project-URL: Repository, https://github.com/deai-network/optio
9
+ Project-URL: Issues, https://github.com/deai-network/optio/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Classifier: Framework :: AsyncIO
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: optio-core<0.2,>=0.1
24
+ Requires-Dist: optio-host<0.2,>=0.1
25
+ Requires-Dist: asyncssh>=2.14
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
29
+
30
+ # optio-opencode
31
+
32
+ Run [opencode web](https://github.com/opencode-ai/opencode) as an [optio](https://github.com/deai-network/optio) task — local subprocess or remote over SSH — with opencode's UI reachable through optio's UI components.
33
+
34
+ ## What it does
35
+
36
+ Given an `OpencodeTaskConfig` (workdir contents, prompt, deliverable callback), `optio-opencode`:
37
+
38
+ 1. Provisions a fresh workdir on the chosen host (local or remote).
39
+ 2. Writes `AGENTS.md` (base prompt + your instructions) and `opencode.json` (your config) into it.
40
+ 3. Installs the opencode binary if missing (remote mode only).
41
+ 4. Launches `opencode web` with a random auth password.
42
+ 5. Registers the opencode UI as a widget that optio's UI components can embed via the widget proxy — SSH tunnel hidden from optio-api.
43
+ 6. Tails a log file the LLM writes to and translates structured lines into optio events:
44
+ - `STATUS: …` → `ctx.report_progress(percent, message)`
45
+ - `DELIVERABLE: <path>` → fetches the file, invokes your `on_deliverable` callback
46
+ - `DONE [summary]` → clean completion
47
+ - `ERROR [message]` → failure
48
+ 7. Cleans up workdir and SSH connection on teardown.
49
+
50
+ The same `OpencodeTaskConfig` works for local and remote modes; only `SSHConfig` differs.
51
+
52
+ ## When to use it
53
+
54
+ You want an opencode-driven assistant session as a managed optio task — surfaced through optio's UI, with progress reporting and file deliverables — without writing the host management, log parsing, or widget plumbing yourself.
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install optio-opencode
60
+ ```
61
+
62
+ Python 3.11+. Depends on `optio-core`, `optio-host`, and `asyncssh`.
63
+
64
+ ## Minimal example
65
+
66
+ ```python
67
+ from optio_opencode import create_opencode_task, OpencodeTaskConfig
68
+ from optio_host import SSHConfig
69
+
70
+ config = OpencodeTaskConfig(
71
+ workdir_files={"AGENTS.md": "Do the thing.", "opencode.json": "{...}"},
72
+ on_deliverable=lambda ctx, path, text: print(f"got {path}: {len(text)} bytes"),
73
+ ssh=SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio"),
74
+ )
75
+
76
+ task = create_opencode_task(config)
77
+ # Schedule / run via optio-core as usual.
78
+ ```
79
+
80
+ Set `ssh=None` for local subprocess mode.
81
+
82
+ ## License
83
+
84
+ Apache-2.0.
@@ -0,0 +1,55 @@
1
+ # optio-opencode
2
+
3
+ Run [opencode web](https://github.com/opencode-ai/opencode) as an [optio](https://github.com/deai-network/optio) task — local subprocess or remote over SSH — with opencode's UI reachable through optio's UI components.
4
+
5
+ ## What it does
6
+
7
+ Given an `OpencodeTaskConfig` (workdir contents, prompt, deliverable callback), `optio-opencode`:
8
+
9
+ 1. Provisions a fresh workdir on the chosen host (local or remote).
10
+ 2. Writes `AGENTS.md` (base prompt + your instructions) and `opencode.json` (your config) into it.
11
+ 3. Installs the opencode binary if missing (remote mode only).
12
+ 4. Launches `opencode web` with a random auth password.
13
+ 5. Registers the opencode UI as a widget that optio's UI components can embed via the widget proxy — SSH tunnel hidden from optio-api.
14
+ 6. Tails a log file the LLM writes to and translates structured lines into optio events:
15
+ - `STATUS: …` → `ctx.report_progress(percent, message)`
16
+ - `DELIVERABLE: <path>` → fetches the file, invokes your `on_deliverable` callback
17
+ - `DONE [summary]` → clean completion
18
+ - `ERROR [message]` → failure
19
+ 7. Cleans up workdir and SSH connection on teardown.
20
+
21
+ The same `OpencodeTaskConfig` works for local and remote modes; only `SSHConfig` differs.
22
+
23
+ ## When to use it
24
+
25
+ You want an opencode-driven assistant session as a managed optio task — surfaced through optio's UI, with progress reporting and file deliverables — without writing the host management, log parsing, or widget plumbing yourself.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install optio-opencode
31
+ ```
32
+
33
+ Python 3.11+. Depends on `optio-core`, `optio-host`, and `asyncssh`.
34
+
35
+ ## Minimal example
36
+
37
+ ```python
38
+ from optio_opencode import create_opencode_task, OpencodeTaskConfig
39
+ from optio_host import SSHConfig
40
+
41
+ config = OpencodeTaskConfig(
42
+ workdir_files={"AGENTS.md": "Do the thing.", "opencode.json": "{...}"},
43
+ on_deliverable=lambda ctx, path, text: print(f"got {path}: {len(text)} bytes"),
44
+ ssh=SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio"),
45
+ )
46
+
47
+ task = create_opencode_task(config)
48
+ # Schedule / run via optio-core as usual.
49
+ ```
50
+
51
+ Set `ssh=None` for local subprocess mode.
52
+
53
+ ## License
54
+
55
+ Apache-2.0.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "optio-opencode"
7
+ version = "0.1.0"
8
+ description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Kristof Csillag", email = "kristof.csillag@deai-labs.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: MacOS",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Topic :: Software Development :: Code Generators",
26
+ "Framework :: AsyncIO",
27
+ ]
28
+ dependencies = [
29
+ "optio-core>=0.1,<0.2",
30
+ "optio-host>=0.1,<0.2",
31
+ "asyncssh>=2.14",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "pytest-asyncio>=0.23",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/deai-network/optio"
42
+ Repository = "https://github.com/deai-network/optio"
43
+ Issues = "https://github.com/deai-network/optio/issues"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ """optio-opencode — run opencode web as an optio task."""
2
+
3
+ import logging as _logging
4
+
5
+ from optio_host import (
6
+ HookContext,
7
+ HookContextProtocol,
8
+ HostCommandError,
9
+ RunResult,
10
+ SSHConfig,
11
+ )
12
+ from optio_opencode.session import create_opencode_task, run_opencode_session
13
+ from optio_opencode.types import (
14
+ DeliverableCallback,
15
+ HookCallback,
16
+ OpencodeTaskConfig,
17
+ )
18
+
19
+ # asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
20
+ # connection...", "Received channel close", etc.) that flood the worker's
21
+ # stdout once an SSH-backed opencode session starts. Quiet it down by
22
+ # default. Consumers that want the verbose trace can override:
23
+ #
24
+ # logging.getLogger("asyncssh").setLevel(logging.INFO)
25
+ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
26
+
27
+ __all__ = [
28
+ "create_opencode_task",
29
+ "run_opencode_session",
30
+ "DeliverableCallback",
31
+ "OpencodeTaskConfig",
32
+ "SSHConfig",
33
+ "HookContext",
34
+ "HookContextProtocol",
35
+ "HostCommandError",
36
+ "RunResult",
37
+ "HookCallback",
38
+ ]
@@ -0,0 +1,432 @@
1
+ """Opencode-specific actions over a generic Host.
2
+
3
+ Each function takes a ``Host`` (from optio_host) and uses only generic
4
+ primitives (``run_command``, ``launch_subprocess``, etc.) plus opencode-
5
+ shaped state-passing. Free functions, not Host methods, so optio-host's
6
+ Host Protocol stays generic.
7
+
8
+ Install path is uniform: ``ensure_opencode_installed`` drives
9
+ csillag/opencode's ``smart-install.sh --check`` and, when needed, downloads
10
+ the release zip as an optio child task (`HookContext.download_file`),
11
+ unpacks it on the host, and places the binary at
12
+ ``<install_dir>/opencode``. ``install_dir`` defaults to
13
+ ``~/.local/bin`` (resolved per host) and is overridable via the
14
+ ``install_dir`` keyword argument on the public entry points; consumers
15
+ expose this as ``OpencodeTaskConfig.opencode_install_dir``.
16
+ No isinstance branches.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import os
23
+ import re
24
+ import shlex
25
+ from typing import TYPE_CHECKING, Callable
26
+
27
+ from optio_host.host import ProcessHandle
28
+
29
+ if TYPE_CHECKING:
30
+ from optio_host.host import Host
31
+
32
+
33
+ _READY_RE = re.compile(r"(http://[^\s]+)")
34
+
35
+ _SMART_INSTALL_URL = (
36
+ "https://raw.githubusercontent.com/csillag/opencode/main/smart-install.sh"
37
+ )
38
+
39
+ # Sub-path of the host's $HOME used as the default opencode install
40
+ # directory when no explicit ``install_dir`` is supplied. Kept as a
41
+ # constant so the three places that care about it (smart-install PATH
42
+ # augmentation, post-ok ``command -v`` lookup, ``_install_opencode_from_zip``
43
+ # install target) stay in agreement.
44
+ DEFAULT_INSTALL_SUBDIR = ".local/bin"
45
+
46
+
47
+ async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
48
+ """Return ``install_dir`` if given, else the host's default install dir.
49
+
50
+ Default: ``<host_home>/<DEFAULT_INSTALL_SUBDIR>``.
51
+ """
52
+ if install_dir is not None:
53
+ return install_dir
54
+ host_home = await host.resolve_host_home()
55
+ return f"{host_home}/{DEFAULT_INSTALL_SUBDIR}"
56
+
57
+
58
+ def _path_augmented(cmd: str, install_dir: str) -> str:
59
+ """Prefix ``cmd`` with an export that prepends ``install_dir`` to PATH.
60
+
61
+ Used so smart-install's internal ``command -v opencode`` and the
62
+ post-"ok" lookup find the binary at the install location even when
63
+ the calling shell's PATH doesn't already include it (common: the
64
+ python process inherits a slimmed-down PATH that doesn't add
65
+ ``~/.local/bin``, so smart-install would falsely report "download"
66
+ and we'd reinstall on every task run).
67
+ """
68
+ return f'export PATH={shlex.quote(install_dir)}:"$PATH"; {cmd}'
69
+
70
+
71
+ async def _smart_install_check(
72
+ host: "Host", *, install_dir: str | None = None,
73
+ ) -> tuple[str, str | None]:
74
+ """Run smart-install.sh --check on ``host`` and parse the result.
75
+
76
+ Returns:
77
+ ("ok", None) when opencode is already up to date.
78
+ ("download", url) when opencode is missing or stale; ``url`` is the
79
+ release-archive zip to fetch.
80
+
81
+ ``install_dir`` is prepended to PATH inside the remote shell so
82
+ smart-install's internal ``command -v opencode`` can see binaries
83
+ installed by a prior ``_install_opencode_from_zip``. Defaults to
84
+ ``~/.local/bin`` on the host.
85
+
86
+ Raises RuntimeError on non-zero exit or unparseable output.
87
+ """
88
+ resolved_install_dir = await _resolve_install_dir(host, install_dir)
89
+ cmd = _path_augmented(
90
+ f"curl -fsSL {_SMART_INSTALL_URL} | bash -s -- --check",
91
+ resolved_install_dir,
92
+ )
93
+ result = await host.run_command(cmd)
94
+ if result.exit_code != 0:
95
+ raise RuntimeError(
96
+ f"smart-install --check failed on host (exit {result.exit_code}): "
97
+ f"{result.stderr.strip()[:200]}"
98
+ )
99
+ line = result.stdout.strip()
100
+ if line == "opencode ok":
101
+ return ("ok", None)
102
+ if line.startswith("download "):
103
+ url = line[len("download "):].strip()
104
+ if not url:
105
+ raise RuntimeError(
106
+ f"smart-install --check returned empty URL: {result.stdout!r}"
107
+ )
108
+ return ("download", url)
109
+ raise RuntimeError(
110
+ f"smart-install --check returned unexpected output: {result.stdout!r}"
111
+ )
112
+
113
+
114
+ async def _install_opencode_from_zip(
115
+ hook_ctx, url: str, *, install_dir: str | None = None,
116
+ ) -> str:
117
+ """Download the opencode release archive from ``url`` and install it.
118
+
119
+ Uniform for LocalHost and RemoteHost:
120
+ 1. mktemp -d on the host.
121
+ 2. ``hook_ctx.download_file(url, <tmpdir>/opencode.zip)`` (spawns the
122
+ child download task — emits its own progress on the child ctx).
123
+ 3. unzip on the host (archive layout: ``bin/opencode`` + sidecars).
124
+ 4. mkdir -p ``install_dir``; move binary there; chmod +x.
125
+ 5. Remove the tempdir.
126
+
127
+ ``install_dir`` defaults to ``~/.local/bin`` on the host when None.
128
+
129
+ Returns the absolute install path on the host.
130
+ """
131
+ host = hook_ctx._host
132
+ resolved_install_dir = await _resolve_install_dir(host, install_dir)
133
+ r = await host.run_command("mktemp -d -t optio-opencode-XXXXXX")
134
+ if r.exit_code != 0:
135
+ raise RuntimeError(
136
+ f"mktemp -d failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
137
+ )
138
+ tmpdir = r.stdout.strip()
139
+ zip_path = f"{tmpdir}/opencode.zip"
140
+ try:
141
+ await hook_ctx.download_file(url, zip_path)
142
+
143
+ r = await host.run_command(
144
+ f"unzip -o -q {shlex.quote(zip_path)} -d {shlex.quote(tmpdir)}"
145
+ )
146
+ if r.exit_code != 0:
147
+ raise RuntimeError(
148
+ f"unzip failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
149
+ )
150
+
151
+ install_path = f"{resolved_install_dir}/opencode"
152
+ r = await host.run_command(f"mkdir -p {shlex.quote(resolved_install_dir)}")
153
+ if r.exit_code != 0:
154
+ raise RuntimeError(
155
+ f"mkdir -p {resolved_install_dir!r} failed (exit {r.exit_code}): "
156
+ f"{r.stderr.strip()[:200]}"
157
+ )
158
+ src = f"{tmpdir}/bin/opencode"
159
+ r = await host.run_command(
160
+ f"mv -f {shlex.quote(src)} {shlex.quote(install_path)}"
161
+ )
162
+ if r.exit_code != 0:
163
+ raise RuntimeError(
164
+ f"mv {src!r} → {install_path!r} failed (exit {r.exit_code}): "
165
+ f"{r.stderr.strip()[:200]}"
166
+ )
167
+ r = await host.run_command(f"chmod +x {shlex.quote(install_path)}")
168
+ if r.exit_code != 0:
169
+ raise RuntimeError(
170
+ f"chmod +x {install_path!r} failed (exit {r.exit_code}): "
171
+ f"{r.stderr.strip()[:200]}"
172
+ )
173
+ return install_path
174
+ finally:
175
+ # Best-effort cleanup. Don't mask a primary exception with cleanup errors.
176
+ await host.run_command(f"rm -rf {shlex.quote(tmpdir)}")
177
+
178
+
179
+ async def ensure_opencode_installed(
180
+ hook_ctx,
181
+ install_if_missing: bool = True,
182
+ *,
183
+ install_dir: str | None = None,
184
+ ) -> str:
185
+ """Ensure opencode is available on the host behind ``hook_ctx``.
186
+
187
+ Uniform local + remote: runs the upstream smart-install.sh in
188
+ ``--check`` mode via ``host.run_command``. If the host already has the
189
+ latest opencode, returns the absolute path that ``command -v opencode``
190
+ resolves to. Otherwise — when ``install_if_missing`` is True — downloads
191
+ the release zip (as an optio child task, so progress shows up in the
192
+ UI), unpacks it, and installs the binary at
193
+ ``<install_dir>/opencode``.
194
+
195
+ ``install_dir`` is the absolute path of the directory that holds (or
196
+ will hold) the ``opencode`` binary on the host. When None (default),
197
+ resolves to ``<host_home>/.local/bin``. Pass an explicit absolute path
198
+ to opt out of the default — the same dir is used for installation, for
199
+ smart-install's PATH lookup, and for the post-"ok" ``command -v``
200
+ resolution, so all three stay in agreement.
201
+
202
+ Returns the absolute path of the opencode binary on the host.
203
+
204
+ Raises RuntimeError when the check is unparseable, when an install is
205
+ needed but ``install_if_missing`` is False, or when any sub-step fails.
206
+ """
207
+ host = hook_ctx._host
208
+ resolved_install_dir = await _resolve_install_dir(host, install_dir)
209
+ # Mark the parent task indeterminate-active before any host I/O so the
210
+ # dashboard shows it working rather than stuck at 0% while the install
211
+ # check (and any subsequent download child task) runs.
212
+ hook_ctx.report_progress(None, "Checking opencode installation…")
213
+ kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
214
+ if kind == "ok":
215
+ # Resolve the on-PATH path. Login shell so ``$HOME``-relative
216
+ # additions from ``~/.profile`` apply (e.g. a manual install at
217
+ # some other location the user has added to PATH), and *also*
218
+ # prepend ``resolved_install_dir`` so our install location wins
219
+ # even when the login profile doesn't add it.
220
+ lookup_inner = _path_augmented(
221
+ "command -v opencode", resolved_install_dir,
222
+ )
223
+ r = await host.run_command(f"bash -lc {shlex.quote(lookup_inner)}")
224
+ if r.exit_code != 0:
225
+ raise RuntimeError(
226
+ "smart-install reported 'opencode ok' but command -v opencode "
227
+ f"failed on the host (exit {r.exit_code}): "
228
+ f"{r.stderr.strip()[:200]}"
229
+ )
230
+ return r.stdout.strip()
231
+ # kind == "download"
232
+ if not install_if_missing:
233
+ raise RuntimeError(
234
+ "opencode is missing or stale on the host and "
235
+ "install_if_missing=False was requested."
236
+ )
237
+ assert url is not None # _smart_install_check guarantees
238
+ hook_ctx.report_progress(None, "Installing opencode…")
239
+ return await _install_opencode_from_zip(
240
+ hook_ctx, url, install_dir=resolved_install_dir,
241
+ )
242
+
243
+
244
+ async def opencode_version(
245
+ host: "Host", *, opencode_executable: str = "opencode",
246
+ ) -> str | None:
247
+ """Return ``<opencode_executable> --version`` stripped stdout, or None.
248
+
249
+ Best-effort — used only for status messages. Returns None on any
250
+ failure (exec error, non-zero exit, empty output).
251
+ """
252
+ try:
253
+ result = await host.run_command(
254
+ f"bash -lc {shlex.quote(opencode_executable + ' --version')}",
255
+ )
256
+ except Exception:
257
+ return None
258
+ if result.exit_code != 0:
259
+ return None
260
+ text = (result.stdout or "").strip()
261
+ return text or None
262
+
263
+
264
+ async def opencode_import(
265
+ host: "Host",
266
+ opencode_db_path: str,
267
+ session_json: bytes,
268
+ *, opencode_executable: str = "opencode",
269
+ ) -> None:
270
+ """Import ``session_json`` into ``opencode_db_path`` on ``host``.
271
+
272
+ Stages the JSON to a scratch file (workdir/snapshot.json) via
273
+ ``put_file_to_host``, runs ``<exec> import <scratch>`` with
274
+ ``OPENCODE_DB`` set, then removes the scratch.
275
+ """
276
+ scratch = f"{host.workdir}/snapshot.json"
277
+ await host.put_file_to_host(bytes(session_json), scratch)
278
+ try:
279
+ result = await host.run_command(
280
+ f"bash -lc {shlex.quote(opencode_executable + ' import ' + shlex.quote(scratch))}",
281
+ env={"OPENCODE_DB": opencode_db_path},
282
+ )
283
+ if result.exit_code != 0:
284
+ raise RuntimeError(
285
+ f"opencode import failed (exit {result.exit_code}): "
286
+ f"{result.stderr}"
287
+ )
288
+ finally:
289
+ await host.remove_file(scratch)
290
+
291
+
292
+ async def opencode_export(
293
+ host: "Host",
294
+ opencode_db_path: str,
295
+ session_id: str,
296
+ *, opencode_executable: str = "opencode",
297
+ ) -> bytes:
298
+ """Export session ``session_id`` from ``opencode_db_path`` on ``host``.
299
+
300
+ Redirects ``<exec> export <id>`` to a scratch file in the workdir
301
+ then ``fetch_bytes_from_host`` returns the contents. The redirect
302
+ avoids a cancellation-truncation bug seen with stdout-via-asyncssh
303
+ captures (see RemoteHost.opencode_export's original comment): under
304
+ cancellation, partial recv-buffer bytes were being committed as a
305
+ snapshot. With the redirect, an aborted run either leaves no file
306
+ (we see exit_code != 0) or a complete one.
307
+ """
308
+ scratch = f"{host.workdir}/.opencode-export.json"
309
+ try:
310
+ result = await host.run_command(
311
+ f"bash -lc "
312
+ f"{shlex.quote(opencode_executable + ' export ' + shlex.quote(session_id) + ' > ' + shlex.quote(scratch))}",
313
+ env={"OPENCODE_DB": opencode_db_path},
314
+ )
315
+ if result.exit_code != 0:
316
+ raise RuntimeError(
317
+ f"opencode export failed (exit {result.exit_code}): "
318
+ f"{result.stderr}"
319
+ )
320
+ return await host.fetch_bytes_from_host(scratch)
321
+ finally:
322
+ await host.remove_file(scratch)
323
+
324
+
325
+ async def launch_opencode(
326
+ host: "Host",
327
+ password: str,
328
+ *,
329
+ ready_timeout_s: float = 30.0,
330
+ opencode_executable: str = "opencode",
331
+ ) -> tuple[ProcessHandle, int]:
332
+ """Launch ``opencode web`` on ``host``; wait for the listening URL.
333
+
334
+ Writes the password to ``<workdir>/.opencode-password`` (mode 600)
335
+ and references it via ``$(cat ...)`` in the launch command so the
336
+ literal value never appears on the remote process's argv.
337
+
338
+ Lays down no-op browser-opener stubs (xdg-open, gio, open,
339
+ sensible-browser) under ``<workdir>/bin`` and prepends that
340
+ directory to PATH so opencode's automatic browser-launch is
341
+ suppressed.
342
+
343
+ Returns ``(handle, opencode_port)``. Caller is responsible for
344
+ eventually terminating the handle via ``host.terminate_subprocess``.
345
+ """
346
+ pw_file = ".opencode-password"
347
+ await host.write_text(pw_file, password)
348
+ await host.run_command(f"chmod 600 {host.workdir}/{pw_file}")
349
+
350
+ # Browser-suppression bin shadow.
351
+ for noop in ("xdg-open", "gio", "open", "sensible-browser"):
352
+ await host.write_text(f"bin/{noop}", "#!/bin/sh\nexit 0\n")
353
+ chmod_result = await host.run_command(f"chmod +x {host.workdir}/bin/*")
354
+ if chmod_result.exit_code != 0:
355
+ # Non-fatal: the noop scripts may fail to be executable, but worst
356
+ # case opencode tries to open a browser and we just live with it.
357
+ pass
358
+
359
+ # Build cmd: read password from file via $(cat), set BROWSER=true,
360
+ # cd to workdir so opencode picks up opencode.json.
361
+ #
362
+ # NOTE: do NOT wrap in `bash -lc` / `bash -l`. A login shell sources
363
+ # the user's profile (~/.profile, ~/.bash_profile, /etc/profile),
364
+ # which on most Linux installs rewrites PATH from scratch and
365
+ # therefore wipes the workdir/bin prefix we set in `env` below. With
366
+ # the prefix gone, the noop xdg-open / sensible-browser / gio / open
367
+ # shadows below stop hiding the real ones and opencode succeeds at
368
+ # opening a real browser window. opencode_executable is an absolute
369
+ # path (resolved by ensure_opencode_installed), so login-shell PATH
370
+ # lookup is not needed to find the binary. Let LocalHost / RemoteHost
371
+ # launch_subprocess do the shell wrapping; we just need the env-var
372
+ # prefix and $(cat ...) substitution, which any POSIX sh handles.
373
+ cmd = (
374
+ f"exec env "
375
+ f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
376
+ f"BROWSER=true "
377
+ f"{opencode_executable} web --port=0 --hostname=127.0.0.1"
378
+ )
379
+
380
+ # Prepend the noop-browsers bin dir to PATH via env on launch_subprocess.
381
+ workdir_bin = f"{host.workdir}/bin"
382
+ extra_path = workdir_bin + ":" + os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
383
+ # OPENCODE_DB must point at the same per-task db file used by the
384
+ # subsequent export/import CLI calls. Without this, the server falls
385
+ # back to opencode's global default db while export/import target the
386
+ # taskdir-local file — causing snapshot capture to "Session not found"
387
+ # against an empty file. Convention: opencode.db is a sibling of the
388
+ # workdir under taskdir (session.py: opencode_db = f"{taskdir}/opencode.db").
389
+ env = {
390
+ "PATH": extra_path,
391
+ "OPENCODE_DB": f"{host.taskdir}/opencode.db",
392
+ }
393
+
394
+ handle = await host.launch_subprocess(cmd, env=env, cwd=host.workdir)
395
+
396
+ async def _read_url() -> int:
397
+ async for raw in handle.stdout:
398
+ if isinstance(raw, bytes):
399
+ line = raw.decode("utf-8", errors="replace").rstrip()
400
+ else:
401
+ line = str(raw).rstrip()
402
+ m = _READY_RE.search(line)
403
+ if m:
404
+ m2 = re.search(r":(\d+)", m.group(1))
405
+ if not m2:
406
+ raise RuntimeError(f"could not find port in URL: {line}")
407
+ return int(m2.group(1))
408
+ raise RuntimeError("opencode exited before printing a URL")
409
+
410
+ try:
411
+ port = await asyncio.wait_for(_read_url(), timeout=ready_timeout_s)
412
+ except asyncio.TimeoutError:
413
+ await host.terminate_subprocess(handle, aggressive=True)
414
+ raise TimeoutError(
415
+ f"opencode did not print a listening URL within {ready_timeout_s}s"
416
+ )
417
+ except BaseException:
418
+ await host.terminate_subprocess(handle, aggressive=True)
419
+ raise
420
+
421
+ return handle, port
422
+
423
+
424
+ async def terminate_opencode(
425
+ host: "Host",
426
+ handle: ProcessHandle,
427
+ *,
428
+ aggressive: bool,
429
+ ) -> None:
430
+ """Thin wrapper over ``host.terminate_subprocess`` — kept for naming
431
+ symmetry with ``launch_opencode``."""
432
+ await host.terminate_subprocess(handle, aggressive=aggressive)