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.
Files changed (35) hide show
  1. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/PKG-INFO +1 -1
  2. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/pyproject.toml +1 -1
  3. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/__init__.py +12 -0
  4. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/host_actions.py +62 -23
  5. optio_opencode-0.1.7/src/optio_opencode/seed_manifest.py +61 -0
  6. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/session.py +248 -1
  7. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/types.py +21 -7
  8. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/PKG-INFO +1 -1
  9. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/SOURCES.txt +5 -0
  10. optio_opencode-0.1.7/tests/test_host_actions.py +196 -0
  11. optio_opencode-0.1.7/tests/test_purge_seed.py +56 -0
  12. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_sanity.py +20 -0
  13. optio_opencode-0.1.7/tests/test_seed_config.py +76 -0
  14. optio_opencode-0.1.7/tests/test_session_seed.py +491 -0
  15. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_smart_install.py +24 -12
  16. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/README.md +0 -0
  17. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/setup.cfg +0 -0
  18. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/prompt.py +0 -0
  19. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode/snapshots.py +0 -0
  20. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  21. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/requires.txt +0 -0
  22. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/src/optio_opencode.egg-info/top_level.txt +0 -0
  23. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_local.py +0 -0
  24. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_primitives_local.py +0 -0
  25. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_primitives_remote.py +0 -0
  26. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_remote_resume.py +0 -0
  27. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_host_resume.py +0 -0
  28. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_prompt.py +0 -0
  29. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_blob_hooks.py +0 -0
  30. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_hooks.py +0 -0
  31. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_local.py +0 -0
  32. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_remote.py +0 -0
  33. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_session_resume.py +0 -0
  34. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_snapshots.py +0 -0
  35. {optio_opencode-0.1.5 → optio_opencode-0.1.7}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.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
- ``~/.local/bin`` (resolved per host) and is overridable via the
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
- # 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"
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
- """Return ``install_dir`` if given, else the host's default install dir.
53
+ """Resolve the opencode binary-cache dir as an absolute path on the worker.
49
54
 
50
- Default: ``<host_home>/<DEFAULT_INSTALL_SUBDIR>``.
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
- host_home = await host.resolve_host_home()
55
- return f"{host_home}/{DEFAULT_INSTALL_SUBDIR}"
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
- ``~/.local/bin``, so smart-install would falsely report "download"
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
- ``~/.local/bin`` on the host.
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 ``~/.local/bin`` on the host when None.
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 ``<host_home>/.local/bin``. Pass an explicit absolute path
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
- # Absolute path on the host where the opencode binary is/should be
28
- # installed. ``None`` (default) ``~/.local/bin`` (user home resolved
29
- # at task start). The same directory is used for installation, for
30
- # smart-install's PATH lookup, and for the post-"ok" ``command -v``
31
- # resolution, so an explicit override stays consistent across all
32
- # three. Must be an absolute path when set.
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0