optio-opencode 0.1.0__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.
- optio_opencode/__init__.py +38 -0
- optio_opencode/host_actions.py +432 -0
- optio_opencode/prompt.py +153 -0
- optio_opencode/session.py +552 -0
- optio_opencode/snapshots.py +101 -0
- optio_opencode/types.py +55 -0
- optio_opencode-0.1.0.dist-info/METADATA +84 -0
- optio_opencode-0.1.0.dist-info/RECORD +10 -0
- optio_opencode-0.1.0.dist-info/WHEEL +5 -0
- optio_opencode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
optio_opencode/prompt.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""System-prompt composition for optio-opencode.
|
|
2
|
+
|
|
3
|
+
The base prompt teaches opencode (via AGENTS.md) how to coordinate with the
|
|
4
|
+
host harness: which log file to append status/deliverable/done/error lines
|
|
5
|
+
to, where to put deliverable files, and how the human expects to be
|
|
6
|
+
addressed. The consumer's own task description is then appended verbatim.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
BASE_PROMPT_PRE = """# Coordination protocol with the host (optio-opencode)
|
|
11
|
+
|
|
12
|
+
You are running inside a coordination harness. Follow these conventions
|
|
13
|
+
throughout the session.
|
|
14
|
+
|
|
15
|
+
## Log channel
|
|
16
|
+
|
|
17
|
+
Append one line per entry to `./optio.log` in this directory. Each line
|
|
18
|
+
must start with one of:
|
|
19
|
+
|
|
20
|
+
- `STATUS:` — progress update for the human. Optional leading percent,
|
|
21
|
+
e.g. `STATUS: 50% counting my fingers`.
|
|
22
|
+
- `DELIVERABLE:` — absolute or workdir-relative path to a file you've
|
|
23
|
+
just produced, e.g. `DELIVERABLE: ./deliverables/summary.md`.
|
|
24
|
+
- `DONE` — you have finished the task. May be followed by an optional
|
|
25
|
+
summary on the same line: `DONE: wrote the report`.
|
|
26
|
+
- `ERROR` — you cannot continue. May be followed by an optional
|
|
27
|
+
message: `ERROR: provider auth failed`.
|
|
28
|
+
|
|
29
|
+
**Every entry must end with a newline character (`\\n`).** The host
|
|
30
|
+
reads `optio.log` with a line-oriented tailer that only emits a line
|
|
31
|
+
once it sees `\\n`; an entry written without a trailing newline (e.g.
|
|
32
|
+
via `printf 'DONE'`) will be buffered indefinitely and never reach the
|
|
33
|
+
host. Use `echo`, `>>` redirection of a heredoc, or any other mechanism
|
|
34
|
+
that guarantees a trailing newline. If unsure, double-check with
|
|
35
|
+
`tail -c 1 ./optio.log` — the result must be a newline.
|
|
36
|
+
|
|
37
|
+
After writing `DONE` or `ERROR`, the session will terminate. Do not
|
|
38
|
+
write further lines.
|
|
39
|
+
|
|
40
|
+
## Deliverables
|
|
41
|
+
|
|
42
|
+
Place files you want to hand back to the host under `./deliverables/`.
|
|
43
|
+
For each file, write a `DELIVERABLE:` log line *after* the file exists
|
|
44
|
+
and its contents are final. The host fetches files by reading these
|
|
45
|
+
log lines.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
BASE_PROMPT_POST = """## Task
|
|
50
|
+
|
|
51
|
+
Here comes the description of your actual task to complete. Throughout
|
|
52
|
+
the task, you are encouraged to narrate progress — both on the normal
|
|
53
|
+
UI and in parallel using the `STATUS:` messages explained above — and
|
|
54
|
+
you are free to ask questions and dialogue with the human. They are
|
|
55
|
+
also working on the same task and will cooperate with you on achieving
|
|
56
|
+
the same goals. So:
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
RESUME_SECTION_TEMPLATE = """## Resumes
|
|
61
|
+
|
|
62
|
+
This harness may pause your session, save your context to a database,
|
|
63
|
+
terminate the underlying process, and later rehydrate it. From your
|
|
64
|
+
point of view the conversation is fully continuous — you keep your
|
|
65
|
+
prior context and will not "notice" the resume.
|
|
66
|
+
|
|
67
|
+
**A resume can happen at any point, not only at the start.** The host
|
|
68
|
+
environment may have changed across a resume — different host,
|
|
69
|
+
different running processes, files outside this workdir gone — even
|
|
70
|
+
though your context remembers everything as alive and well.
|
|
71
|
+
|
|
72
|
+
**The workdir (this directory) is preserved across resumes, with two
|
|
73
|
+
caveats:**
|
|
74
|
+
|
|
75
|
+
- {excludes_clause}
|
|
76
|
+
- **Anything outside the workdir is not preserved.**
|
|
77
|
+
|
|
78
|
+
{outside_clause}
|
|
79
|
+
|
|
80
|
+
### Detecting a resume: `resume.log`
|
|
81
|
+
|
|
82
|
+
Each session start (fresh or resumed) appends one ISO 8601 timestamp
|
|
83
|
+
to `./resume.log`. The very first line is the original launch
|
|
84
|
+
timestamp; each subsequent line is a resume.
|
|
85
|
+
|
|
86
|
+
**At the start of every new incoming user message, read
|
|
87
|
+
`./resume.log` first.** Compare the latest line to the value you
|
|
88
|
+
remembered last time you checked. If a new line has appeared, treat
|
|
89
|
+
the situation as a resume:
|
|
90
|
+
|
|
91
|
+
- Verify any tools, processes, or files you previously gathered
|
|
92
|
+
outside the workdir are still where you left them.
|
|
93
|
+
- Re-establish anything that's gone (re-launch a server, re-fetch a
|
|
94
|
+
file, etc.) before continuing.
|
|
95
|
+
- Then resume the work you were doing.
|
|
96
|
+
|
|
97
|
+
If a resume slips past unnoticed, a failing tool call is the
|
|
98
|
+
next-best signal — re-check `./resume.log` then.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _render_resume_section(workdir_exclude: list[str] | None) -> str:
|
|
103
|
+
"""Render the RESUME_SECTION_TEMPLATE with the effective exclude list."""
|
|
104
|
+
from optio_host.archive import DEFAULT_WORKDIR_EXCLUDES
|
|
105
|
+
effective = workdir_exclude if workdir_exclude is not None else DEFAULT_WORKDIR_EXCLUDES
|
|
106
|
+
if not effective:
|
|
107
|
+
excludes_clause = (
|
|
108
|
+
"**No paths are excluded** — every file in the workdir is preserved."
|
|
109
|
+
)
|
|
110
|
+
outside_clause = (
|
|
111
|
+
"If you need to stash large data, place it outside the workdir "
|
|
112
|
+
"(e.g. `/tmp/`) — but remember it may be missing when you next look."
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
excludes_str = ", ".join(f"`{p}`" for p in effective)
|
|
116
|
+
excludes_clause = (
|
|
117
|
+
f"**Paths matching the snapshot exclude list are NOT preserved**, "
|
|
118
|
+
f"even inside the workdir. The current exclude list is: {excludes_str}."
|
|
119
|
+
)
|
|
120
|
+
outside_clause = (
|
|
121
|
+
"If you need to stash large data, place it outside the workdir "
|
|
122
|
+
"(e.g. `/tmp/`) or inside an excluded subdirectory — but remember "
|
|
123
|
+
"any such location may be missing when you next look."
|
|
124
|
+
)
|
|
125
|
+
return RESUME_SECTION_TEMPLATE.format(
|
|
126
|
+
excludes_clause=excludes_clause,
|
|
127
|
+
outside_clause=outside_clause,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compose_agents_md(
|
|
132
|
+
consumer_instructions: str,
|
|
133
|
+
*,
|
|
134
|
+
workdir_exclude: list[str] | None,
|
|
135
|
+
supports_resume: bool = True,
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Build the full AGENTS.md body.
|
|
138
|
+
|
|
139
|
+
Parameters:
|
|
140
|
+
consumer_instructions: the task author's prompt, appended verbatim.
|
|
141
|
+
workdir_exclude: the snapshot exclude list for this task. Mandatory:
|
|
142
|
+
callers must pass it explicitly to prevent silent desync between
|
|
143
|
+
archive.py defaults and the prompt's claims about what's preserved.
|
|
144
|
+
Pass None to render with the framework defaults.
|
|
145
|
+
supports_resume: when False, the resume-detection section is omitted
|
|
146
|
+
from the prompt. Default True.
|
|
147
|
+
"""
|
|
148
|
+
body = consumer_instructions.rstrip()
|
|
149
|
+
if supports_resume:
|
|
150
|
+
resume_block = _render_resume_section(workdir_exclude) + "\n"
|
|
151
|
+
else:
|
|
152
|
+
resume_block = ""
|
|
153
|
+
return f"{BASE_PROMPT_PRE}\n{resume_block}{BASE_PROMPT_POST}\n{body}\n"
|