optio-opencode 0.1.5__tar.gz → 0.1.7__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.
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/PKG-INFO +1 -1
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/pyproject.toml +1 -1
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/__init__.py +12 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/host_actions.py +62 -23
- optio_opencode-0.1.7/src/optio_opencode/seed_manifest.py +61 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/session.py +248 -1
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/types.py +21 -7
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/PKG-INFO +1 -1
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/SOURCES.txt +5 -0
- optio_opencode-0.1.7/tests/test_host_actions.py +196 -0
- optio_opencode-0.1.7/tests/test_purge_seed.py +56 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_sanity.py +20 -0
- optio_opencode-0.1.7/tests/test_seed_config.py +76 -0
- optio_opencode-0.1.7/tests/test_session_seed.py +491 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_smart_install.py +24 -12
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/README.md +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/setup.cfg +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/requires.txt +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_local.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_prompt.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_blob_hooks.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_hooks.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_local.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_remote.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_resume.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_snapshots.py +0 -0
- {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_types.py +0 -0
|
@@ -14,6 +14,13 @@ from optio_opencode.types import (
|
|
|
14
14
|
HookCallback,
|
|
15
15
|
OpencodeTaskConfig,
|
|
16
16
|
)
|
|
17
|
+
from optio_opencode.seed_manifest import (
|
|
18
|
+
OPENCODE_SEED_MANIFEST,
|
|
19
|
+
OPENCODE_SEED_SUFFIX,
|
|
20
|
+
delete_seed,
|
|
21
|
+
list_seeds,
|
|
22
|
+
purge_seed,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
# asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
|
|
19
26
|
# connection...", "Received channel close", etc.) that flood the worker's
|
|
@@ -34,4 +41,9 @@ __all__ = [
|
|
|
34
41
|
"HostCommandError",
|
|
35
42
|
"RunResult",
|
|
36
43
|
"HookCallback",
|
|
44
|
+
"OPENCODE_SEED_MANIFEST",
|
|
45
|
+
"OPENCODE_SEED_SUFFIX",
|
|
46
|
+
"delete_seed",
|
|
47
|
+
"list_seeds",
|
|
48
|
+
"purge_seed",
|
|
37
49
|
]
|
|
@@ -9,8 +9,10 @@ Install path is uniform: ``ensure_opencode_installed`` drives
|
|
|
9
9
|
csillag/opencode's ``smart-install.sh --check`` and, when needed, downloads
|
|
10
10
|
the release zip as an optio child task (`HookContext.download_file`),
|
|
11
11
|
unpacks it on the host, and places the binary at
|
|
12
|
-
``<install_dir>/opencode``. ``install_dir`` defaults to
|
|
13
|
-
|
|
12
|
+
``<install_dir>/opencode``. ``install_dir`` defaults to the optio-owned
|
|
13
|
+
binary cache on the worker
|
|
14
|
+
(``OPENCODE_CACHE_DIR`` / ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin``,
|
|
15
|
+
resolved per host — never the host home bin dir) and is overridable via the
|
|
14
16
|
``install_dir`` keyword argument on the public entry points; consumers
|
|
15
17
|
expose this as ``OpencodeTaskConfig.opencode_install_dir``.
|
|
16
18
|
No isinstance branches.
|
|
@@ -36,23 +38,53 @@ _SMART_INSTALL_URL = (
|
|
|
36
38
|
"https://raw.githubusercontent.com/csillag/opencode/main/smart-install.sh"
|
|
37
39
|
)
|
|
38
40
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# install
|
|
44
|
-
|
|
41
|
+
# The optio-owned opencode binary cache lives on the WORKER, never in the host
|
|
42
|
+
# user's home bin dir. Default cache:
|
|
43
|
+
# ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin``, overridable via the
|
|
44
|
+
# ``OPENCODE_CACHE_DIR`` env var on the worker. Kept as a constant so the places
|
|
45
|
+
# that care about it (smart-install PATH augmentation, post-ok ``command -v``
|
|
46
|
+
# lookup, ``_install_opencode_from_zip`` install target) stay in agreement.
|
|
47
|
+
_OPENCODE_CACHE_SHELL_DEFAULT = (
|
|
48
|
+
'${OPENCODE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin}'
|
|
49
|
+
)
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
|
|
48
|
-
"""
|
|
53
|
+
"""Resolve the opencode binary-cache dir as an absolute path on the worker.
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
``install_dir`` (config.opencode_install_dir) overrides. Else the worker's
|
|
56
|
+
OPENCODE_CACHE_DIR / XDG_CACHE_HOME / $HOME decide it — resolved via a shell
|
|
57
|
+
echo so RemoteHost gets the remote cache. Resolved from the worker's REAL env
|
|
58
|
+
(this runs before per-task XDG isolation), so the cache stays shared and
|
|
59
|
+
outside any workdir → never snapshotted; evictable → smart-install re-downloads."""
|
|
52
60
|
if install_dir is not None:
|
|
53
|
-
return install_dir
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
return install_dir.rstrip("/")
|
|
62
|
+
r = await host.run_command(f'printf %s "{_OPENCODE_CACHE_SHELL_DEFAULT}"')
|
|
63
|
+
path = r.stdout.strip()
|
|
64
|
+
if r.exit_code != 0 or not path:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"failed to resolve opencode cache dir on host (exit {r.exit_code}): "
|
|
67
|
+
f"{r.stderr.strip()[:200]}"
|
|
68
|
+
)
|
|
69
|
+
return path.rstrip("/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _isolation_env(host: "Host") -> dict[str, str]:
|
|
73
|
+
"""Per-task HOME/XDG isolation env, derived from ``host.workdir``.
|
|
74
|
+
|
|
75
|
+
Merged into the launch env AND the export/import env so opencode's
|
|
76
|
+
auth/config/data go per-task under ``<workdir>/home`` (where the seed
|
|
77
|
+
manifest's ``home_subdir`` and merge/capture target). Distinct from the
|
|
78
|
+
binary cache (``_resolve_install_dir``), which is shared and resolved from
|
|
79
|
+
the worker's REAL env before this isolation applies.
|
|
80
|
+
"""
|
|
81
|
+
home = f"{host.workdir.rstrip('/')}/home"
|
|
82
|
+
return {
|
|
83
|
+
"HOME": home,
|
|
84
|
+
"XDG_CONFIG_HOME": f"{home}/.config",
|
|
85
|
+
"XDG_DATA_HOME": f"{home}/.local/share",
|
|
86
|
+
"XDG_CACHE_HOME": f"{home}/.cache",
|
|
87
|
+
}
|
|
56
88
|
|
|
57
89
|
|
|
58
90
|
def _path_augmented(cmd: str, install_dir: str) -> str:
|
|
@@ -61,9 +93,9 @@ def _path_augmented(cmd: str, install_dir: str) -> str:
|
|
|
61
93
|
Used so smart-install's internal ``command -v opencode`` and the
|
|
62
94
|
post-"ok" lookup find the binary at the install location even when
|
|
63
95
|
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
|
-
|
|
66
|
-
and we'd reinstall on every task run).
|
|
96
|
+
python process inherits a slimmed-down PATH that doesn't add the
|
|
97
|
+
optio-owned opencode binary cache dir, so smart-install would falsely
|
|
98
|
+
report "download" and we'd reinstall on every task run).
|
|
67
99
|
"""
|
|
68
100
|
return f'export PATH={shlex.quote(install_dir)}:"$PATH"; {cmd}'
|
|
69
101
|
|
|
@@ -80,8 +112,9 @@ async def _smart_install_check(
|
|
|
80
112
|
|
|
81
113
|
``install_dir`` is prepended to PATH inside the remote shell so
|
|
82
114
|
smart-install's internal ``command -v opencode`` can see binaries
|
|
83
|
-
installed by a prior ``_install_opencode_from_zip``. Defaults to
|
|
84
|
-
|
|
115
|
+
installed by a prior ``_install_opencode_from_zip``. Defaults to the
|
|
116
|
+
optio-owned opencode binary cache on the worker (see
|
|
117
|
+
``_resolve_install_dir``).
|
|
85
118
|
|
|
86
119
|
Raises RuntimeError on non-zero exit or unparseable output.
|
|
87
120
|
"""
|
|
@@ -124,7 +157,8 @@ async def _install_opencode_from_zip(
|
|
|
124
157
|
4. mkdir -p ``install_dir``; move binary there; chmod +x.
|
|
125
158
|
5. Remove the tempdir.
|
|
126
159
|
|
|
127
|
-
``install_dir`` defaults to
|
|
160
|
+
``install_dir`` defaults to the optio-owned opencode binary cache on
|
|
161
|
+
the worker when None (see ``_resolve_install_dir``).
|
|
128
162
|
|
|
129
163
|
Returns the absolute install path on the host.
|
|
130
164
|
"""
|
|
@@ -194,7 +228,9 @@ async def ensure_opencode_installed(
|
|
|
194
228
|
|
|
195
229
|
``install_dir`` is the absolute path of the directory that holds (or
|
|
196
230
|
will hold) the ``opencode`` binary on the host. When None (default),
|
|
197
|
-
resolves to
|
|
231
|
+
resolves to the optio-owned binary cache on the worker
|
|
232
|
+
(``OPENCODE_CACHE_DIR`` / ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin``;
|
|
233
|
+
see ``_resolve_install_dir``). Pass an explicit absolute path
|
|
198
234
|
to opt out of the default — the same dir is used for installation, for
|
|
199
235
|
smart-install's PATH lookup, and for the post-"ok" ``command -v``
|
|
200
236
|
resolution, so all three stay in agreement.
|
|
@@ -278,7 +314,7 @@ async def opencode_import(
|
|
|
278
314
|
try:
|
|
279
315
|
result = await host.run_command(
|
|
280
316
|
f"bash -lc {shlex.quote(opencode_executable + ' import ' + shlex.quote(scratch))}",
|
|
281
|
-
env={"OPENCODE_DB": opencode_db_path},
|
|
317
|
+
env={**_isolation_env(host), "OPENCODE_DB": opencode_db_path},
|
|
282
318
|
)
|
|
283
319
|
if result.exit_code != 0:
|
|
284
320
|
raise RuntimeError(
|
|
@@ -310,7 +346,7 @@ async def opencode_export(
|
|
|
310
346
|
result = await host.run_command(
|
|
311
347
|
f"bash -lc "
|
|
312
348
|
f"{shlex.quote(opencode_executable + ' export ' + shlex.quote(session_id) + ' > ' + shlex.quote(scratch))}",
|
|
313
|
-
env={"OPENCODE_DB": opencode_db_path},
|
|
349
|
+
env={**_isolation_env(host), "OPENCODE_DB": opencode_db_path},
|
|
314
350
|
)
|
|
315
351
|
if result.exit_code != 0:
|
|
316
352
|
raise RuntimeError(
|
|
@@ -381,7 +417,10 @@ async def launch_opencode(
|
|
|
381
417
|
# against an empty file. Convention: opencode.db is a sibling of the
|
|
382
418
|
# workdir under taskdir (session.py: opencode_db = f"{taskdir}/opencode.db").
|
|
383
419
|
# The browser-suppression env (PATH prepend + BROWSER) comes from extra_env.
|
|
420
|
+
# The HOME/XDG isolation env (from _isolation_env) points opencode's
|
|
421
|
+
# auth/config/data at <workdir>/home so per-task seeding works.
|
|
384
422
|
env = {
|
|
423
|
+
**_isolation_env(host),
|
|
385
424
|
"OPENCODE_DB": f"{host.taskdir}/opencode.db",
|
|
386
425
|
**(extra_env or {}),
|
|
387
426
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""opencode adopter of the generic optio-agents seed engine.
|
|
2
|
+
|
|
3
|
+
Defines the opencode seed manifest (HOME layout + capture-time include
|
|
4
|
+
triage), the Mongo collection suffix, and ergonomic `delete_seed` /
|
|
5
|
+
`list_seeds` / `purge_seed` wrappers that bind the suffix for consuming
|
|
6
|
+
apps.
|
|
7
|
+
|
|
8
|
+
Unlike claudecode, opencode needs no consume-time rekey: its auth/config
|
|
9
|
+
are cwd-independent, so `consume_transform` is None.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from optio_agents import seeds
|
|
15
|
+
|
|
16
|
+
OPENCODE_SEED_SUFFIX = "_opencode_seeds"
|
|
17
|
+
OPENCODE_SEED_MANIFEST_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
OPENCODE_SEED_MANIFEST = seeds.SeedManifest(
|
|
21
|
+
home_subdir="home",
|
|
22
|
+
include=[
|
|
23
|
+
".local/share/opencode/auth.json",
|
|
24
|
+
".config/opencode/opencode.json",
|
|
25
|
+
".config/opencode/plugins",
|
|
26
|
+
],
|
|
27
|
+
version=OPENCODE_SEED_MANIFEST_VERSION,
|
|
28
|
+
consume_transform=None, # no cwd-rekey for opencode
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def delete_seed(store, seed_id: str):
|
|
33
|
+
"""Delete an opencode seed doc; returns its GridFS blobId (or None).
|
|
34
|
+
|
|
35
|
+
Takes an optio store binding (``optio.mongo_store`` — exposes ``db`` and
|
|
36
|
+
``prefix``) as-is, so consuming apps hand over the whole namespace handle
|
|
37
|
+
instead of threading db+prefix (or knowing the collection suffix). The
|
|
38
|
+
caller still removes the returned blob from GridFS.
|
|
39
|
+
"""
|
|
40
|
+
return await seeds.delete_seed(
|
|
41
|
+
store.db, prefix=store.prefix, suffix=OPENCODE_SEED_SUFFIX, seed_id=seed_id,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def list_seeds(store) -> list[dict]:
|
|
46
|
+
"""List opencode seeds as [{seedId, createdAt}, ...]. Takes an optio store
|
|
47
|
+
binding (``optio.mongo_store``) as-is."""
|
|
48
|
+
return await seeds.list_seeds(store.db, prefix=store.prefix, suffix=OPENCODE_SEED_SUFFIX)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def purge_seed(store, seed_id: str):
|
|
52
|
+
"""Purge an opencode seed (doc + its GridFS blob) in one call.
|
|
53
|
+
|
|
54
|
+
Takes an optio store binding (``optio.mongo_store``) as-is, per the Shared-
|
|
55
|
+
contracts surface. Mirrors `optio_claudecode.purge_seed`; both are thin
|
|
56
|
+
re-exports of the `optio_agents.seeds.purge_seed` engine, which expunges
|
|
57
|
+
the seed doc and its GridFS blob and raises KeyError if absent.
|
|
58
|
+
"""
|
|
59
|
+
return await seeds.purge_seed(
|
|
60
|
+
store.db, prefix=store.prefix, suffix=OPENCODE_SEED_SUFFIX, seed_id=seed_id,
|
|
61
|
+
)
|
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
19
|
import base64
|
|
20
|
+
import inspect
|
|
20
21
|
import json
|
|
21
22
|
import logging
|
|
22
23
|
import os
|
|
@@ -33,8 +34,10 @@ from optio_agents import HookContext
|
|
|
33
34
|
from optio_host.host import Host, LocalHost, ProcessHandle, RemoteHost
|
|
34
35
|
from optio_host.paths import task_dir
|
|
35
36
|
from optio_agents.protocol.session import _SessionFailed, run_log_protocol_session
|
|
37
|
+
from optio_agents import seeds as _seeds
|
|
36
38
|
from optio_opencode import host_actions
|
|
37
39
|
from optio_opencode.prompt import compose_agents_md
|
|
40
|
+
from optio_opencode.seed_manifest import OPENCODE_SEED_MANIFEST, OPENCODE_SEED_SUFFIX
|
|
38
41
|
from optio_agents import get_protocol
|
|
39
42
|
from optio_opencode.snapshots import (
|
|
40
43
|
insert_snapshot,
|
|
@@ -49,6 +52,10 @@ _LOG = logging.getLogger(__name__)
|
|
|
49
52
|
|
|
50
53
|
READY_TIMEOUT_S = 30.0
|
|
51
54
|
|
|
55
|
+
# Fresh-launch kickoff prompt POSTed to the pre-created opencode session so the
|
|
56
|
+
# agent starts the task unattended. Suppressed on resume.
|
|
57
|
+
AUTO_START_PROMPT = "Read AGENTS.md and execute the task it describes"
|
|
58
|
+
|
|
52
59
|
|
|
53
60
|
def _build_host(config: OpencodeTaskConfig, process_id: str) -> Host:
|
|
54
61
|
"""Construct the appropriate Host object for the given config.
|
|
@@ -84,6 +91,9 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
84
91
|
opencode_exec: str = "opencode"
|
|
85
92
|
session_id: str | None = None
|
|
86
93
|
preserved_session_id: str | None = None
|
|
94
|
+
# Worker-side opencode port; hoisted so the finally can query the live
|
|
95
|
+
# server (for the seed model default) before terminating it.
|
|
96
|
+
worker_port: int | None = None
|
|
87
97
|
|
|
88
98
|
# --- resume decision (BEFORE the protocol session starts) -------------
|
|
89
99
|
resume_requested = bool(getattr(ctx, "resume", False))
|
|
@@ -161,6 +171,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
161
171
|
terminate the subprocess and capture the snapshot.
|
|
162
172
|
"""
|
|
163
173
|
nonlocal launched_handle, opencode_exec, session_id, preserved_session_id
|
|
174
|
+
nonlocal worker_port
|
|
164
175
|
|
|
165
176
|
refreshed_files: list[str] = []
|
|
166
177
|
if not resuming:
|
|
@@ -182,6 +193,18 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
182
193
|
await host.write_text(
|
|
183
194
|
"opencode.json", json.dumps(config.opencode_config, indent=2),
|
|
184
195
|
)
|
|
196
|
+
if config.seed_id is not None:
|
|
197
|
+
# Seeded fresh: overlay the stored environment into
|
|
198
|
+
# <workdir>/home, where the launch's XDG_DATA_HOME /
|
|
199
|
+
# XDG_CONFIG_HOME point, so the seeded auth.json / opencode.json
|
|
200
|
+
# are used. Begins a NEW session — no resume.
|
|
201
|
+
await _seeds.merge_seed(
|
|
202
|
+
ctx, host,
|
|
203
|
+
seed_id=config.seed_id,
|
|
204
|
+
manifest=OPENCODE_SEED_MANIFEST,
|
|
205
|
+
suffix=OPENCODE_SEED_SUFFIX,
|
|
206
|
+
decrypt=config.session_blob_decrypt,
|
|
207
|
+
)
|
|
185
208
|
# Note: do NOT call ctx.clear_has_saved_state() here. The spec
|
|
186
209
|
# described it as "belt-and-braces", but in practice it makes
|
|
187
210
|
# `hasSavedState` track the live session rather than the durable
|
|
@@ -282,10 +305,28 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
282
305
|
"iframeSrc": f"{{widgetProxyUrl}}{_workdir_b64}/session/{session_id}",
|
|
283
306
|
"localStorageOverrides": {
|
|
284
307
|
"opencode.settings.dat:defaultServerUrl": "{widgetProxyUrl}",
|
|
308
|
+
# Start with the review/diff panel collapsed. opencode defaults
|
|
309
|
+
# it OPEN (layout store `review.panelOpened ?? true`), eating
|
|
310
|
+
# the right half of the iframe with a panel that is useless in
|
|
311
|
+
# this embedded context. The persist layer deep-merges this
|
|
312
|
+
# partial blob into the layout defaults, so only panelOpened is
|
|
313
|
+
# forced false; the operator can still toggle it open per
|
|
314
|
+
# session. (UI-state key — if opencode renames the `layout`
|
|
315
|
+
# store it silently reverts to default-open.)
|
|
316
|
+
"opencode.global.dat:layout": '{"review": {"panelOpened": false}}',
|
|
285
317
|
},
|
|
286
318
|
})
|
|
287
319
|
ctx.report_progress(None, "opencode is live")
|
|
288
320
|
|
|
321
|
+
# auto_start: on a fresh launch, POST the kickoff prompt to the
|
|
322
|
+
# pre-created session so opencode starts the task unattended.
|
|
323
|
+
# Suppressed on resume (the restored session already carries its
|
|
324
|
+
# conversation; re-prompting would re-trigger the task).
|
|
325
|
+
if config.auto_start and not resuming:
|
|
326
|
+
await _post_opencode_prompt(
|
|
327
|
+
worker_port, password, session_id, AUTO_START_PROMPT,
|
|
328
|
+
)
|
|
329
|
+
|
|
289
330
|
# --- await opencode subprocess exit -----------------------------
|
|
290
331
|
# The protocol driver runs this body alongside the tail dispatcher
|
|
291
332
|
# and a cancel watcher. When the user cancels, the driver cancels
|
|
@@ -329,12 +370,51 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
329
370
|
if not ctx.should_continue():
|
|
330
371
|
cancelled = True
|
|
331
372
|
|
|
373
|
+
# Resolve the operator's last-used model BEFORE terminating opencode
|
|
374
|
+
# (the query needs the live server). Synthesised into the seed's
|
|
375
|
+
# opencode.json below so an unattended seeded session runs that model
|
|
376
|
+
# rather than opencode's first-provider fallback. Best-effort.
|
|
377
|
+
seed_model: str | None = None
|
|
378
|
+
if (
|
|
379
|
+
not resuming
|
|
380
|
+
and config.on_seed_saved is not None
|
|
381
|
+
and worker_port is not None
|
|
382
|
+
and session_id is not None
|
|
383
|
+
):
|
|
384
|
+
try:
|
|
385
|
+
seed_model = await _resolve_session_model(
|
|
386
|
+
worker_port, password, session_id,
|
|
387
|
+
)
|
|
388
|
+
except Exception: # noqa: BLE001
|
|
389
|
+
_LOG.exception(
|
|
390
|
+
"seed model resolution failed; seed will carry no model default",
|
|
391
|
+
)
|
|
392
|
+
|
|
332
393
|
if launched_handle is not None:
|
|
333
394
|
try:
|
|
334
395
|
await host.terminate_subprocess(launched_handle, aggressive=cancelled)
|
|
335
396
|
except Exception: # noqa: BLE001
|
|
336
397
|
_LOG.exception("terminate_subprocess failed")
|
|
337
398
|
|
|
399
|
+
if not resuming and config.on_seed_saved is not None:
|
|
400
|
+
try:
|
|
401
|
+
if seed_model is not None:
|
|
402
|
+
# Write the model default into the seed's opencode.json
|
|
403
|
+
# before capture so it travels in the seed.
|
|
404
|
+
await _write_seed_model_config(host, seed_model)
|
|
405
|
+
seed_id_out = await _seeds.capture_seed(
|
|
406
|
+
ctx, host,
|
|
407
|
+
manifest=OPENCODE_SEED_MANIFEST,
|
|
408
|
+
suffix=OPENCODE_SEED_SUFFIX,
|
|
409
|
+
encrypt=config.session_blob_encrypt,
|
|
410
|
+
)
|
|
411
|
+
# 2nd arg: the resolved "providerID/modelID" (or None).
|
|
412
|
+
await _call_maybe_async(
|
|
413
|
+
config.on_seed_saved, seed_id_out, seed_model,
|
|
414
|
+
)
|
|
415
|
+
except Exception: # noqa: BLE001
|
|
416
|
+
_LOG.exception("opencode seed capture failed; callback not fired")
|
|
417
|
+
|
|
338
418
|
if config.supports_resume and session_id is not None:
|
|
339
419
|
try:
|
|
340
420
|
await _capture_snapshot(
|
|
@@ -384,6 +464,13 @@ async def _read_blob_bytes(ctx: ProcessContext, blob_id) -> bytes:
|
|
|
384
464
|
return bytes(out)
|
|
385
465
|
|
|
386
466
|
|
|
467
|
+
async def _call_maybe_async(fn, *args) -> None:
|
|
468
|
+
"""Invoke a callback that may be sync or async."""
|
|
469
|
+
result = fn(*args)
|
|
470
|
+
if inspect.isawaitable(result):
|
|
471
|
+
await result
|
|
472
|
+
|
|
473
|
+
|
|
387
474
|
async def _capture_snapshot(
|
|
388
475
|
ctx: ProcessContext,
|
|
389
476
|
host: Host,
|
|
@@ -611,13 +698,172 @@ async def _create_opencode_session(port: int, password: str, directory: str) ->
|
|
|
611
698
|
)
|
|
612
699
|
|
|
613
700
|
|
|
701
|
+
def _post_opencode_prompt_sync(
|
|
702
|
+
port: int, password: str, session_id: str, message: str,
|
|
703
|
+
) -> None:
|
|
704
|
+
"""Blocking HTTP POST of a kickoff prompt to opencode's session route.
|
|
705
|
+
|
|
706
|
+
Called via an executor from :func:`_post_opencode_prompt`. Mirrors
|
|
707
|
+
:func:`_create_opencode_session_sync`'s BasicAuth + transient-retry pattern
|
|
708
|
+
(the first request over a freshly-opened SSH local forward occasionally
|
|
709
|
+
drops while asyncssh wires up the channel).
|
|
710
|
+
|
|
711
|
+
Targets opencode's v1 fire-and-forget route ``POST
|
|
712
|
+
/session/:sessionID/prompt_async`` (same ``/session`` prefix as
|
|
713
|
+
:func:`_create_opencode_session_sync`). Its ``PromptPayload`` body
|
|
714
|
+
requires ``parts`` — so ``{"parts": [{"type": "text", "text": msg}]}``.
|
|
715
|
+
``prompt_async`` "starts the session if needed and returns immediately"
|
|
716
|
+
(204 No Content), which is the unattended auto-start semantics we want;
|
|
717
|
+
the sync ``/session/:id/message`` route blocks streaming the whole AI
|
|
718
|
+
response. Instance routing comes from opencode's ``process.cwd()`` (the
|
|
719
|
+
workdir), so no ``?directory=`` query is needed.
|
|
720
|
+
|
|
721
|
+
(Earlier targets were both wrong against opencode 1.14.x and crashed the
|
|
722
|
+
task: the experimental v2 route ``/api/session/:id/prompt`` 400s every
|
|
723
|
+
body with ``Expected Session.Message`` — the retries exhaust, a
|
|
724
|
+
RuntimeError aborts the session, opencode is torn down, and the web UI
|
|
725
|
+
502s its own backend.)
|
|
726
|
+
"""
|
|
727
|
+
import base64 as _b64
|
|
728
|
+
import time
|
|
729
|
+
import urllib.request
|
|
730
|
+
from urllib.error import URLError
|
|
731
|
+
|
|
732
|
+
auth_token = _b64.b64encode(f"opencode:{password}".encode("utf-8")).decode("ascii")
|
|
733
|
+
url = f"http://127.0.0.1:{port}/session/{session_id}/prompt_async"
|
|
734
|
+
headers = {
|
|
735
|
+
"content-type": "application/json",
|
|
736
|
+
"authorization": f"Basic {auth_token}",
|
|
737
|
+
}
|
|
738
|
+
payload = json.dumps(
|
|
739
|
+
{"parts": [{"type": "text", "text": message}]}
|
|
740
|
+
).encode("utf-8")
|
|
741
|
+
|
|
742
|
+
last_exc: Exception | None = None
|
|
743
|
+
for attempt in range(4):
|
|
744
|
+
if attempt > 0:
|
|
745
|
+
time.sleep(0.15 * attempt)
|
|
746
|
+
req = urllib.request.Request(url, method="POST", data=payload, headers=headers)
|
|
747
|
+
try:
|
|
748
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
749
|
+
resp.read()
|
|
750
|
+
return
|
|
751
|
+
except (URLError, ConnectionError, OSError) as exc:
|
|
752
|
+
last_exc = exc
|
|
753
|
+
continue
|
|
754
|
+
raise RuntimeError(
|
|
755
|
+
f"opencode session prompt failed after retries: {last_exc!r}"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def _post_opencode_prompt(
|
|
760
|
+
port: int, password: str, session_id: str, message: str,
|
|
761
|
+
) -> None:
|
|
762
|
+
loop = asyncio.get_event_loop()
|
|
763
|
+
await loop.run_in_executor(
|
|
764
|
+
None, _post_opencode_prompt_sync, port, password, session_id, message
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _resolve_session_model_sync(
|
|
769
|
+
port: int, password: str, session_id: str,
|
|
770
|
+
) -> str | None:
|
|
771
|
+
"""Best-effort: return the operator's last-used model as
|
|
772
|
+
``"providerID/modelID"``, or None.
|
|
773
|
+
|
|
774
|
+
GETs opencode's ``/session/:sessionID/message`` (the same ``/session``
|
|
775
|
+
prefix as :func:`_create_opencode_session_sync`) and walks the message
|
|
776
|
+
list; each ``info.role == "assistant"`` message carries ``providerID`` /
|
|
777
|
+
``modelID``. The LAST such message wins — the operator may switch models
|
|
778
|
+
mid-session, and their final choice is the one to seed.
|
|
779
|
+
|
|
780
|
+
Used at seed-capture time (opencode must still be alive) to synthesise the
|
|
781
|
+
model default into the seed's ``opencode.json``. Returns None on any
|
|
782
|
+
transport/parse error or when no assistant message exists; the caller then
|
|
783
|
+
skips writing a default, leaving opencode's own resolution in place (no
|
|
784
|
+
worse than before)."""
|
|
785
|
+
import base64 as _b64
|
|
786
|
+
import urllib.request
|
|
787
|
+
from urllib.error import URLError
|
|
788
|
+
|
|
789
|
+
auth_token = _b64.b64encode(f"opencode:{password}".encode("utf-8")).decode("ascii")
|
|
790
|
+
url = f"http://127.0.0.1:{port}/session/{session_id}/message"
|
|
791
|
+
req = urllib.request.Request(
|
|
792
|
+
url, method="GET", headers={"authorization": f"Basic {auth_token}"},
|
|
793
|
+
)
|
|
794
|
+
try:
|
|
795
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
796
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
797
|
+
except (URLError, ConnectionError, OSError, ValueError):
|
|
798
|
+
return None
|
|
799
|
+
if not isinstance(data, list):
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
model: str | None = None
|
|
803
|
+
for item in data:
|
|
804
|
+
info = item.get("info", item) if isinstance(item, dict) else {}
|
|
805
|
+
if not isinstance(info, dict) or info.get("role") != "assistant":
|
|
806
|
+
continue
|
|
807
|
+
prov, mod = info.get("providerID"), info.get("modelID")
|
|
808
|
+
if isinstance(prov, str) and prov and isinstance(mod, str) and mod:
|
|
809
|
+
model = f"{prov}/{mod}"
|
|
810
|
+
return model
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def _resolve_session_model(
|
|
814
|
+
port: int, password: str, session_id: str,
|
|
815
|
+
) -> str | None:
|
|
816
|
+
loop = asyncio.get_event_loop()
|
|
817
|
+
return await loop.run_in_executor(
|
|
818
|
+
None, _resolve_session_model_sync, port, password, session_id
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
async def _write_seed_model_config(host: Host, model_str: str) -> None:
|
|
823
|
+
"""Merge ``{"model": model_str}`` into the seed's XDG opencode config at
|
|
824
|
+
``<workdir>/home/.config/opencode/opencode.json`` (creating it if absent,
|
|
825
|
+
preserving any existing keys).
|
|
826
|
+
|
|
827
|
+
``<workdir>/home`` is the seed manifest's ``home_subdir``, and
|
|
828
|
+
``.config/opencode/opencode.json`` is in the manifest's include list, so
|
|
829
|
+
the file travels in the captured seed. On consume, opencode's
|
|
830
|
+
``defaultModel()`` reads ``cfg.model`` first — so an unattended seeded
|
|
831
|
+
session (auto-start ``prompt_async`` sends no model) runs the operator's
|
|
832
|
+
model instead of the first-provider fallback (anthropic with no key →
|
|
833
|
+
``invalid x-api-key``)."""
|
|
834
|
+
cfg_dir = f"{host.workdir}/home/.config/opencode"
|
|
835
|
+
cfg_path = f"{cfg_dir}/opencode.json"
|
|
836
|
+
await host.run_command(f"mkdir -p {shlex.quote(cfg_dir)}")
|
|
837
|
+
|
|
838
|
+
existing: dict = {}
|
|
839
|
+
r = await host.run_command(f"cat {shlex.quote(cfg_path)}")
|
|
840
|
+
if r.exit_code == 0 and r.stdout.strip():
|
|
841
|
+
try:
|
|
842
|
+
parsed = json.loads(r.stdout)
|
|
843
|
+
if isinstance(parsed, dict):
|
|
844
|
+
existing = parsed
|
|
845
|
+
except ValueError:
|
|
846
|
+
existing = {}
|
|
847
|
+
existing["model"] = model_str
|
|
848
|
+
await host.write_text(
|
|
849
|
+
"home/.config/opencode/opencode.json", json.dumps(existing, indent=2),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
614
853
|
def create_opencode_task(
|
|
615
854
|
process_id: str,
|
|
616
855
|
name: str,
|
|
617
856
|
config: OpencodeTaskConfig,
|
|
618
857
|
description: str | None = None,
|
|
858
|
+
metadata: dict | None = None,
|
|
619
859
|
) -> TaskInstance:
|
|
620
|
-
"""Return a TaskInstance that runs one opencode web session.
|
|
860
|
+
"""Return a TaskInstance that runs one opencode web session.
|
|
861
|
+
|
|
862
|
+
``metadata`` is the caller app's task-tagging payload (for later
|
|
863
|
+
filter/select/identify); it is stamped onto the TaskInstance verbatim and
|
|
864
|
+
never read by the task itself. Construction is the caller's concern — this
|
|
865
|
+
factory only accepts and forwards it.
|
|
866
|
+
"""
|
|
621
867
|
|
|
622
868
|
async def _execute(ctx: ProcessContext) -> None:
|
|
623
869
|
await run_opencode_session(ctx, config)
|
|
@@ -629,4 +875,5 @@ def create_opencode_task(
|
|
|
629
875
|
description=description,
|
|
630
876
|
ui_widget="iframe",
|
|
631
877
|
supports_resume=config.supports_resume,
|
|
878
|
+
metadata=metadata or {},
|
|
632
879
|
)
|
|
@@ -7,7 +7,7 @@ is owned by ``optio-host``. This module re-exports them so existing
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass, field
|
|
10
|
-
from typing import Any, Callable
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
11
11
|
|
|
12
12
|
from optio_agents.protocol.session import DeliverableCallback, HookCallback
|
|
13
13
|
from optio_host.types import SSHConfig
|
|
@@ -24,12 +24,14 @@ class OpencodeTaskConfig:
|
|
|
24
24
|
ssh: SSHConfig | None = None
|
|
25
25
|
on_deliverable: DeliverableCallback | None = None
|
|
26
26
|
install_if_missing: bool = True
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
27
|
+
# Override for the optio-owned opencode binary **cache** directory (where
|
|
28
|
+
# the opencode binary is installed/cached on the worker). ``None``
|
|
29
|
+
# (default) → the worker's ``OPENCODE_CACHE_DIR`` or
|
|
30
|
+
# ``${XDG_CACHE_HOME:-$HOME/.cache}/optio-opencode/bin``. Never the host
|
|
31
|
+
# user's ``~/.local/bin``. The same directory is used for installation,
|
|
32
|
+
# for smart-install's ``--check`` lookup, and for the post-"ok"
|
|
33
|
+
# ``command -v`` resolution, so an explicit override stays consistent
|
|
34
|
+
# across all three. Must be an absolute path when set.
|
|
33
35
|
opencode_install_dir: str | None = None
|
|
34
36
|
workdir_exclude: list[str] | None = None
|
|
35
37
|
supports_resume: bool = True
|
|
@@ -51,6 +53,18 @@ class OpencodeTaskConfig:
|
|
|
51
53
|
# → no refresh; the resumed session keeps its original AGENTS.md.
|
|
52
54
|
on_resume_refresh: Callable[["OpencodeTaskConfig"], "OpencodeTaskConfig"] | None = None
|
|
53
55
|
|
|
56
|
+
# --- seed surface (mirrors optio-claudecode) ---
|
|
57
|
+
seed_id: str | None = None
|
|
58
|
+
# Fired on teardown of a fresh session after a successful capture, with
|
|
59
|
+
# two args: (seed_id, info). ``info`` is a human-readable summary of the
|
|
60
|
+
# captured configuration — for opencode the resolved "providerID/modelID"
|
|
61
|
+
# (or None if no model was used), mirroring claudecode's account summary.
|
|
62
|
+
on_seed_saved: "Callable[[str, str | None], Awaitable[None] | None] | None" = None
|
|
63
|
+
# Fresh launch kicks the agent off unattended via the opencode session API
|
|
64
|
+
# (POST /api/session/<id>/prompt "Read AGENTS.md and execute the task it
|
|
65
|
+
# describes"); suppressed on resume.
|
|
66
|
+
auto_start: bool = False
|
|
67
|
+
|
|
54
68
|
def __post_init__(self) -> None:
|
|
55
69
|
e = self.session_blob_encrypt is not None
|
|
56
70
|
d = self.session_blob_decrypt is not None
|