optio-opencode 0.2.0__py3-none-any.whl → 0.2.2__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.
@@ -10,17 +10,22 @@ from optio_host import (
10
10
  )
11
11
  from optio_opencode.session import create_opencode_task, run_opencode_session
12
12
  from optio_opencode.types import (
13
+ ConversationMode,
13
14
  DeliverableCallback,
14
15
  HookCallback,
15
16
  OpencodeTaskConfig,
17
+ SeedProvider,
18
+ ToolVerbosity,
16
19
  )
17
20
  from optio_opencode.seed_manifest import (
21
+ OPENCODE_CRED_MANIFEST,
18
22
  OPENCODE_SEED_MANIFEST,
19
23
  OPENCODE_SEED_SUFFIX,
20
24
  delete_seed,
21
25
  list_seeds,
22
26
  purge_seed,
23
27
  )
28
+ from optio_opencode.verify import verify_and_refresh_seed
24
29
 
25
30
  # asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
26
31
  # connection...", "Received channel close", etc.) that flood the worker's
@@ -46,4 +51,9 @@ __all__ = [
46
51
  "delete_seed",
47
52
  "list_seeds",
48
53
  "purge_seed",
54
+ "OPENCODE_CRED_MANIFEST",
55
+ "SeedProvider",
56
+ "ConversationMode",
57
+ "ToolVerbosity",
58
+ "verify_and_refresh_seed",
49
59
  ]
@@ -0,0 +1,357 @@
1
+ """OpencodeConversation — engine-side driver for one opencode session over
2
+ the spawned server's native HTTP+SSE API.
3
+
4
+ The session body launches the opencode server (``launch_opencode``),
5
+ pre-creates a session, constructs this object with the same
6
+ ``(worker_port, password, session_id)`` it already produces, publishes it via
7
+ ``ctx.publish_result``, and runs ``run_reader()`` until teardown.
8
+
9
+ Live events come from ``GET /global/event`` — the per-instance
10
+ ``/event?directory=…`` endpoint closes immediately after ``server.connected``
11
+ on the shipped server (verified empirically against 1.14.45, Task 8 fixtures).
12
+ Each ``/global/event`` frame wraps the event as
13
+ ``{"directory"?, "project"?, "payload": {"id", "type", "properties"}}``
14
+ (``server.connected``/``server.heartbeat`` carry no ``directory``); the driver
15
+ drops frames for other directories and fans the unwrapped payload out to
16
+ ``on_event`` subscribers as a dict, unmodified (``{"id", "type",
17
+ "properties"}``). Synthetic events use the ``x-optio-`` type prefix.
18
+ Permission requests are event-driven (``permission.asked``) with a
19
+ list-endpoint sweep on every SSE (re)connect, so requests that fired during a
20
+ stream gap are never lost.
21
+
22
+ See docs/2026-06-11-opencode-conversation-mode-design.md.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import os
31
+
32
+ import aiohttp
33
+
34
+ from optio_agents.conversation import (
35
+ ConversationClosed,
36
+ PermissionDecision,
37
+ PermissionRequest,
38
+ )
39
+
40
+ _LOG = logging.getLogger(__name__)
41
+
42
+ # Reconnect backoff for the SSE reader (capped; the session body cancels the
43
+ # reader at teardown, so there is no give-up path while the task is alive).
44
+ _RECONNECT_DELAYS = (0.2, 0.5, 1.0, 2.0, 5.0)
45
+
46
+
47
+ class OpencodeConversation:
48
+ """Implements optio_agents.conversation.Conversation for opencode."""
49
+
50
+ def __init__(
51
+ self, *, port: int, password: str, session_id: str, directory: str,
52
+ ) -> None:
53
+ self._base = f"http://127.0.0.1:{port}"
54
+ self._auth = aiohttp.BasicAuth("opencode", password)
55
+ self._session_id = session_id
56
+ self._directory = directory
57
+ # The server resolves instance directories (symlinks etc.) before
58
+ # stamping them onto /global/event frames; compare against realpath too.
59
+ self._directory_real = os.path.realpath(directory)
60
+ self._pending = False
61
+ self._closed = asyncio.Event()
62
+ self._close_reason: str | None = None
63
+ # Cooperative-shutdown request towards the owning task body.
64
+ self.close_requested = asyncio.Event()
65
+ self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
66
+ self._event_handlers: list = []
67
+ self._message_handlers: list = []
68
+ self._permission_handler = None
69
+ self._queued_permission_requests: list[dict] = []
70
+ self._answered_permissions: set[str] = set()
71
+ # Text parts of the in-flight assistant message, keyed by part id —
72
+ # joined and fired via on_message when the message completes.
73
+ self._part_texts: dict[str, dict[str, str]] = {}
74
+ self._dispatcher_task: asyncio.Task | None = None
75
+ self._http: aiohttp.ClientSession | None = None
76
+
77
+ # -- wiring ------------------------------------------------------------
78
+
79
+ async def run_reader(self) -> None:
80
+ """Connect to /global/event and dispatch frames until cancelled (by
81
+ the session body at teardown) or closed. Reconnects with backoff."""
82
+ self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
83
+ self._http = aiohttp.ClientSession(auth=self._auth)
84
+ attempt = 0
85
+ try:
86
+ while not self._closed.is_set():
87
+ try:
88
+ await self._consume_sse()
89
+ attempt = 0 # clean EOF: server still alive, reconnect fresh
90
+ except (aiohttp.ClientError, ConnectionError, asyncio.TimeoutError) as exc:
91
+ _LOG.info("conversation: SSE drop (%s); reconnecting", exc)
92
+ delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
93
+ attempt += 1
94
+ await asyncio.sleep(delay)
95
+ finally:
96
+ await self._finish("process ended")
97
+ await self._http.close()
98
+
99
+ def _url(self, path: str) -> str:
100
+ return f"{self._base}{path}"
101
+
102
+ def _params(self) -> dict:
103
+ return {"directory": self._directory}
104
+
105
+ async def _consume_sse(self) -> None:
106
+ timeout = aiohttp.ClientTimeout(total=None, sock_connect=10)
107
+ # /global/event, not /event?directory=…: the per-instance endpoint
108
+ # ends its stream right after server.connected (observed on 1.14.45),
109
+ # so we take the global firehose and filter by directory ourselves.
110
+ async with self._http.get(
111
+ self._url("/global/event"), timeout=timeout,
112
+ ) as resp:
113
+ resp.raise_for_status()
114
+ # A (re)connect can postdate permission.asked events we never saw.
115
+ await self._sweep_permissions()
116
+ data_lines: list[str] = []
117
+ async for raw in resp.content:
118
+ line = raw.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
119
+ if line.startswith("data:"):
120
+ data_lines.append(line[5:].lstrip())
121
+ continue
122
+ if line == "" and data_lines:
123
+ payload = "\n".join(data_lines)
124
+ data_lines = []
125
+ try:
126
+ obj = json.loads(payload)
127
+ except ValueError:
128
+ _LOG.warning("conversation: unparseable SSE data: %.200s", payload)
129
+ self._event_queue.put_nowait(
130
+ {"type": "x-optio-unparseable", "line": payload},
131
+ )
132
+ continue
133
+ self._route_frame(obj)
134
+
135
+ # -- event routing -------------------------------------------------------
136
+
137
+ def _route_frame(self, obj: dict) -> None:
138
+ """Unwrap one /global/event frame: drop other directories' events,
139
+ route the inner ``{"id", "type", "properties"}`` payload. Bare
140
+ (unwrapped) frames are routed as-is for fake/forward compatibility."""
141
+ frame_dir = obj.get("directory")
142
+ if (
143
+ frame_dir is not None
144
+ and frame_dir != self._directory
145
+ and os.path.realpath(frame_dir) != self._directory_real
146
+ ):
147
+ return
148
+ payload = obj.get("payload")
149
+ self._route(payload if isinstance(payload, dict) else obj)
150
+
151
+ def _for_this_session(self, props: dict) -> bool:
152
+ sid = (
153
+ props.get("sessionID")
154
+ or (props.get("info") or {}).get("sessionID")
155
+ or (props.get("part") or {}).get("sessionID")
156
+ )
157
+ return sid is None or sid == self._session_id
158
+
159
+ def _route(self, obj: dict) -> None:
160
+ t = obj.get("type") or ""
161
+ props = obj.get("properties") or {}
162
+ if t == "permission.asked" and self._for_this_session(props):
163
+ self._on_permission_asked(props)
164
+ elif t == "message.part.updated":
165
+ part = props.get("part") or {}
166
+ if part.get("type") == "text" and self._for_this_session(props):
167
+ mid, pid = str(part.get("messageID")), str(part.get("id"))
168
+ self._part_texts.setdefault(mid, {})[pid] = part.get("text") or ""
169
+ elif t == "message.updated":
170
+ info = props.get("info") or {}
171
+ if (
172
+ info.get("role") == "assistant"
173
+ and (info.get("time") or {}).get("completed")
174
+ and self._for_this_session(props)
175
+ ):
176
+ parts = self._part_texts.pop(str(info.get("id")), {})
177
+ if parts:
178
+ self._fire_message("\n\n".join(parts.values()))
179
+ elif t in ("session.status", "session.idle") and self._for_this_session(props):
180
+ status = props.get("status") or {}
181
+ if t == "session.idle" or status.get("type") == "idle":
182
+ self._pending = False
183
+ elif status.get("type") == "busy":
184
+ self._pending = True
185
+ self._event_queue.put_nowait(obj)
186
+
187
+ async def _dispatch_loop(self) -> None:
188
+ while True:
189
+ obj = await self._event_queue.get()
190
+ for handler in list(self._event_handlers):
191
+ await self._call_handler(handler, obj, "on_event")
192
+
193
+ async def _call_handler(self, handler, arg, label: str) -> None:
194
+ try:
195
+ result = handler(arg)
196
+ if asyncio.iscoroutine(result):
197
+ await result
198
+ except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
199
+ _LOG.exception("conversation: %s handler raised", label)
200
+
201
+ def _fire_message(self, text: str) -> None:
202
+ for handler in list(self._message_handlers):
203
+ asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
204
+
205
+ # -- permission gate -------------------------------------------------------
206
+
207
+ def _on_permission_asked(self, props: dict) -> None:
208
+ rid = str(props.get("id") or "")
209
+ if not rid or rid in self._answered_permissions:
210
+ return
211
+ if self._permission_handler is None:
212
+ # Queue until a handler is registered: opencode blocks the session
213
+ # on the unanswered ask, which closes the publish/registration
214
+ # race. Documented caller contract: register promptly.
215
+ self._queued_permission_requests.append(props)
216
+ return
217
+ asyncio.ensure_future(self._answer_permission(props))
218
+
219
+ async def _sweep_permissions(self) -> None:
220
+ """Fetch pending permission requests and feed unanswered ones for our
221
+ session to the gate. Gap-safety: covers asks fired while the SSE
222
+ stream was down (opencode's /global/event has no server-side replay)."""
223
+ try:
224
+ async with self._http.get(
225
+ self._url("/permission"), params=self._params(),
226
+ ) as resp:
227
+ resp.raise_for_status()
228
+ pending = await resp.json()
229
+ except (aiohttp.ClientError, ConnectionError, ValueError) as exc:
230
+ _LOG.warning("conversation: permission sweep failed: %s", exc)
231
+ return
232
+ for props in pending:
233
+ if props.get("sessionID") in (None, self._session_id):
234
+ self._on_permission_asked(props)
235
+
236
+ async def _answer_permission(self, props: dict) -> None:
237
+ rid = str(props.get("id"))
238
+ if rid in self._answered_permissions:
239
+ return
240
+ self._answered_permissions.add(rid)
241
+ request = PermissionRequest(
242
+ tool_name=str(props.get("permission") or ""),
243
+ input=props.get("metadata") or {},
244
+ raw=props,
245
+ )
246
+ try:
247
+ decision = await self._permission_handler(request)
248
+ except Exception: # noqa: BLE001
249
+ _LOG.exception("conversation: permission handler raised; denying")
250
+ decision = PermissionDecision(
251
+ behavior="deny", message="optio harness: permission handler failed",
252
+ )
253
+ # opencode reply vocabulary: allow → "once" (never "always": the optio
254
+ # gate decides per request); deny → "reject". updated_input has no
255
+ # opencode equivalent and is ignored.
256
+ body: dict = (
257
+ {"reply": "once"} if decision.behavior == "allow"
258
+ else {"reply": "reject", "message": decision.message or "Denied by the operator."}
259
+ )
260
+ try:
261
+ async with self._http.post(
262
+ self._url(f"/permission/{rid}/reply"),
263
+ params=self._params(), json=body,
264
+ ) as resp:
265
+ if resp.status >= 400:
266
+ _LOG.warning(
267
+ "conversation: permission reply %s → HTTP %s "
268
+ "(likely already answered elsewhere)", rid, resp.status,
269
+ )
270
+ except (aiohttp.ClientError, ConnectionError) as exc:
271
+ _LOG.warning("conversation: permission reply failed: %s", exc)
272
+ self._event_queue.put_nowait({
273
+ "type": "x-optio-permission-answered",
274
+ "request_id": rid,
275
+ "behavior": decision.behavior,
276
+ })
277
+
278
+ # -- Conversation protocol surface --------------------------------------
279
+
280
+ async def send(self, text: str) -> None:
281
+ if self._closed.is_set():
282
+ raise ConversationClosed(self._close_reason or "conversation closed")
283
+ self._pending = True
284
+ try:
285
+ async with self._http.post(
286
+ self._url(f"/session/{self._session_id}/prompt_async"),
287
+ params=self._params(),
288
+ json={"parts": [{"type": "text", "text": text}]},
289
+ ) as resp:
290
+ resp.raise_for_status()
291
+ except (aiohttp.ClientError, ConnectionError) as exc:
292
+ self._pending = False
293
+ raise ConversationClosed(f"send failed: {exc}") from exc
294
+
295
+ def on_event(self, handler):
296
+ self._event_handlers.append(handler)
297
+ return lambda: self._event_handlers.remove(handler)
298
+
299
+ def on_message(self, handler):
300
+ self._message_handlers.append(handler)
301
+ return lambda: self._message_handlers.remove(handler)
302
+
303
+ def on_permission_request(self, handler):
304
+ self._permission_handler = handler
305
+ queued, self._queued_permission_requests = (
306
+ self._queued_permission_requests, [],
307
+ )
308
+ for props in queued:
309
+ asyncio.ensure_future(self._answer_permission(props))
310
+
311
+ def _unsub() -> None:
312
+ if self._permission_handler is handler:
313
+ self._permission_handler = None
314
+ return _unsub
315
+
316
+ def is_pending(self) -> bool:
317
+ return self._pending
318
+
319
+ async def interrupt(self) -> None:
320
+ if self._closed.is_set():
321
+ raise ConversationClosed(self._close_reason or "conversation closed")
322
+ if not self._pending:
323
+ return
324
+ async with self._http.post(
325
+ self._url(f"/session/{self._session_id}/abort"),
326
+ params=self._params(), json={},
327
+ ) as resp:
328
+ resp.raise_for_status()
329
+
330
+ async def close(self) -> None:
331
+ self.close_requested.set()
332
+
333
+ @property
334
+ def closed(self) -> bool:
335
+ return self._closed.is_set()
336
+
337
+ # -- internals -----------------------------------------------------------
338
+
339
+ async def _finish(self, reason: str) -> None:
340
+ if self._closed.is_set():
341
+ return
342
+ self._closed.set()
343
+ self._close_reason = reason
344
+ self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
345
+ # Stop the dispatcher, then drain whatever it left in the queue so
346
+ # subscribers are guaranteed to see the final x-optio-closed event.
347
+ if self._dispatcher_task is not None:
348
+ self._dispatcher_task.cancel()
349
+ try:
350
+ await self._dispatcher_task
351
+ except asyncio.CancelledError:
352
+ pass
353
+ self._dispatcher_task = None
354
+ while not self._event_queue.empty():
355
+ obj = self._event_queue.get_nowait()
356
+ for handler in list(self._event_handlers):
357
+ await self._call_handler(handler, obj, "on_event")
@@ -0,0 +1,129 @@
1
+ """In-session credential save-back for opencode seeds.
2
+
3
+ OAuth providers with rotating refresh tokens (xAI, OpenAI/Codex) make
4
+ refresh tokens single-use: opencode's plugin loader() refreshes a token on
5
+ use, the provider rotates the refresh token, and opencode persists the
6
+ rotated pair to auth.json (best-effort). This watcher keeps the seed
7
+ current by writing the changed in-session auth.json back into the existing
8
+ seed, plus a final backstop at teardown. Provider-agnostic: opencode does
9
+ the refreshing; the watcher only persists the file.
10
+
11
+ The seed is the single source of truth for credentials; see
12
+ docs/2026-06-11-opencode-seed-save-back-design.md.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ from typing import Callable
22
+
23
+ from optio_agents import seeds
24
+ from optio_host.host import Host
25
+
26
+ from optio_opencode.seed_manifest import OPENCODE_CRED_MANIFEST, OPENCODE_SEED_SUFFIX
27
+
28
+ _LOG = logging.getLogger(__name__)
29
+
30
+ CRED_WATCH_INTERVAL_S = 10.0
31
+ _CRED_RELPATH = "home/.local/share/opencode/auth.json"
32
+ _MODEL_RELPATH = "home/.config/opencode/opencode.json"
33
+
34
+
35
+ async def cred_fingerprint(host: Host) -> str | None:
36
+ """SHA-256 of the live auth.json, or None when it is missing,
37
+ unparseable, or carries no provider entry (i.e. nothing worth saving
38
+ back). The multi-provider analog of claudecode's refresh-token gate —
39
+ guards against corrupting a seed with a half-written/logged-out file."""
40
+ path = f"{host.workdir.rstrip('/')}/{_CRED_RELPATH}"
41
+ try:
42
+ raw = await host.fetch_bytes_from_host(path)
43
+ except FileNotFoundError:
44
+ return None
45
+ try:
46
+ data = json.loads(raw.decode("utf-8"))
47
+ except (ValueError, UnicodeDecodeError):
48
+ return None
49
+ if not isinstance(data, dict) or not data:
50
+ return None
51
+ return hashlib.sha256(raw).hexdigest()
52
+
53
+
54
+ async def capture_gate_ok(host: Host) -> bool:
55
+ """Stricter gate for seed CAPTURE: valid auth.json (cred_fingerprint)
56
+ AND a non-empty `model` in the live opencode.json. A model-less seed is
57
+ unusable — a consuming task gets no default and verify has nothing to
58
+ probe. Save-back deliberately does NOT use this gate: save-back only
59
+ replaces auth.json (the seed's opencode.json is untouched), and blocking
60
+ it over an unrelated field would drop a rotated refresh token."""
61
+ if await cred_fingerprint(host) is None:
62
+ return False
63
+ path = f"{host.workdir.rstrip('/')}/{_MODEL_RELPATH}"
64
+ try:
65
+ raw = await host.fetch_bytes_from_host(path)
66
+ cfg = json.loads(raw.decode("utf-8"))
67
+ except (FileNotFoundError, ValueError, UnicodeDecodeError):
68
+ return False
69
+ return isinstance(cfg, dict) and bool(cfg.get("model"))
70
+
71
+
72
+ async def save_back_if_changed(
73
+ ctx,
74
+ host: Host,
75
+ *,
76
+ seed_id: str,
77
+ baseline: str | None,
78
+ encrypt: "Callable[[bytes], bytes] | None",
79
+ decrypt: "Callable[[bytes], bytes] | None",
80
+ ) -> str | None:
81
+ """If the live auth.json differs from `baseline` and is valid, save it
82
+ back into the seed and return the new fingerprint. Otherwise return
83
+ `baseline` unchanged. Never raises — save-back is best-effort."""
84
+ fp = await cred_fingerprint(host)
85
+ if fp is None or fp == baseline:
86
+ return baseline
87
+ try:
88
+ await seeds.refresh_seed(
89
+ ctx, host, seed_id=seed_id, manifest=OPENCODE_CRED_MANIFEST,
90
+ suffix=OPENCODE_SEED_SUFFIX, encrypt=encrypt, decrypt=decrypt,
91
+ )
92
+ _LOG.info("seed %s: auth.json saved back", seed_id)
93
+ return fp
94
+ except Exception:
95
+ _LOG.exception("seed %s: auth.json save-back failed", seed_id)
96
+ return baseline
97
+
98
+
99
+ async def run_credential_watcher(
100
+ ctx,
101
+ host: Host,
102
+ *,
103
+ seed_id: str,
104
+ baseline: str | None,
105
+ encrypt: "Callable[[bytes], bytes] | None",
106
+ decrypt: "Callable[[bytes], bytes] | None",
107
+ lease_holder: str | None = None,
108
+ ) -> None:
109
+ """Poll every CRED_WATCH_INTERVAL_S: save back rotated auth.json, and
110
+ (when `lease_holder` is set) renew the seed's lease. If the lease is
111
+ lost, signal the session to stop (set the cancellation flag) and exit —
112
+ continuing would mean a token-rotation collision with the new holder.
113
+ Runs until cancelled. Best-effort save-back; lease-loss is decisive."""
114
+ current = baseline
115
+ while True:
116
+ await asyncio.sleep(CRED_WATCH_INTERVAL_S)
117
+ current = await save_back_if_changed(
118
+ ctx, host, seed_id=seed_id, baseline=current,
119
+ encrypt=encrypt, decrypt=decrypt,
120
+ )
121
+ if lease_holder is not None:
122
+ ok = await seeds.renew_lease(
123
+ ctx._db, prefix=ctx._prefix, suffix=OPENCODE_SEED_SUFFIX,
124
+ seed_id=seed_id, holder=lease_holder,
125
+ )
126
+ if not ok:
127
+ _LOG.warning("seed %s: lease lost; aborting session", seed_id)
128
+ ctx.cancellation_flag.set()
129
+ return
@@ -145,14 +145,20 @@ async def _smart_install_check(
145
145
 
146
146
 
147
147
  async def _install_opencode_from_zip(
148
- hook_ctx, url: str, *, install_dir: str | None = None,
148
+ host: "Host",
149
+ download: "Callable[[str, str], Awaitable[None]]",
150
+ url: str,
151
+ *,
152
+ install_dir: str | None = None,
149
153
  ) -> str:
150
154
  """Download the opencode release archive from ``url`` and install it.
151
155
 
152
156
  Uniform for LocalHost and RemoteHost:
153
157
  1. mktemp -d on the host.
154
- 2. ``hook_ctx.download_file(url, <tmpdir>/opencode.zip)`` (spawns the
155
- child download task — emits its own progress on the child ctx).
158
+ 2. ``download(url, <tmpdir>/opencode.zip)`` (engine callers pass
159
+ ``hook_ctx.download_file``, which spawns the child download task —
160
+ emits its own progress on the child ctx; engine-less callers pass
161
+ ``curl_downloader(host)``).
156
162
  3. unzip on the host (archive layout: ``bin/opencode`` + sidecars).
157
163
  4. mkdir -p ``install_dir``; move binary there; chmod +x.
158
164
  5. Remove the tempdir.
@@ -162,7 +168,6 @@ async def _install_opencode_from_zip(
162
168
 
163
169
  Returns the absolute install path on the host.
164
170
  """
165
- host = hook_ctx._host
166
171
  resolved_install_dir = await _resolve_install_dir(host, install_dir)
167
172
  r = await host.run_command("mktemp -d -t optio-opencode-XXXXXX")
168
173
  if r.exit_code != 0:
@@ -172,7 +177,7 @@ async def _install_opencode_from_zip(
172
177
  tmpdir = r.stdout.strip()
173
178
  zip_path = f"{tmpdir}/opencode.zip"
174
179
  try:
175
- await hook_ctx.download_file(url, zip_path)
180
+ await download(url, zip_path)
176
181
 
177
182
  r = await host.run_command(
178
183
  f"unzip -o -q {shlex.quote(zip_path)} -d {shlex.quote(tmpdir)}"
@@ -211,20 +216,23 @@ async def _install_opencode_from_zip(
211
216
 
212
217
 
213
218
  async def ensure_opencode_installed(
214
- hook_ctx,
215
- install_if_missing: bool = True,
219
+ host: "Host",
216
220
  *,
221
+ download: "Callable[[str, str], Awaitable[None]]",
222
+ report_progress: "Callable | None" = None,
223
+ install_if_missing: bool = True,
217
224
  install_dir: str | None = None,
218
225
  ) -> str:
219
- """Ensure opencode is available on the host behind ``hook_ctx``.
226
+ """Ensure opencode is available on ``host``.
220
227
 
221
228
  Uniform local + remote: runs the upstream smart-install.sh in
222
229
  ``--check`` mode via ``host.run_command``. If the host already has the
223
230
  latest opencode, returns the absolute path that ``command -v opencode``
224
- resolves to. Otherwise — when ``install_if_missing`` is True — downloads
225
- the release zip (as an optio child task, so progress shows up in the
226
- UI), unpacks it, and installs the binary at
227
- ``<install_dir>/opencode``.
231
+ resolves to. Otherwise — when ``install_if_missing`` is True — fetches
232
+ the release zip via ``download`` (engine callers pass
233
+ ``hook_ctx.download_file``, so progress shows up in the UI as an optio
234
+ child task; engine-less callers pass ``curl_downloader(host)``),
235
+ unpacks it, and installs the binary at ``<install_dir>/opencode``.
228
236
 
229
237
  ``install_dir`` is the absolute path of the directory that holds (or
230
238
  will hold) the ``opencode`` binary on the host. When None (default),
@@ -235,17 +243,23 @@ async def ensure_opencode_installed(
235
243
  smart-install's PATH lookup, and for the post-"ok" ``command -v``
236
244
  resolution, so all three stay in agreement.
237
245
 
246
+ INVARIANT: install-dir resolution (_resolve_install_dir) runs against the
247
+ host's REAL environment, never under _isolation_env. If the per-task
248
+ isolation env leaked in, XDG_CACHE_HOME would point inside the (possibly
249
+ throwaway) workdir: the binary would re-download per run and be deleted
250
+ at teardown. The shared worker cache must stay outside every workdir.
251
+
238
252
  Returns the absolute path of the opencode binary on the host.
239
253
 
240
254
  Raises RuntimeError when the check is unparseable, when an install is
241
255
  needed but ``install_if_missing`` is False, or when any sub-step fails.
242
256
  """
243
- host = hook_ctx._host
244
257
  resolved_install_dir = await _resolve_install_dir(host, install_dir)
245
258
  # Mark the parent task indeterminate-active before any host I/O so the
246
259
  # dashboard shows it working rather than stuck at 0% while the install
247
260
  # check (and any subsequent download child task) runs.
248
- hook_ctx.report_progress(None, "Checking opencode installation…")
261
+ if report_progress is not None:
262
+ report_progress(None, "Checking opencode installation…")
249
263
  kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
250
264
  if kind == "ok":
251
265
  # Resolve the on-PATH path. Login shell so ``$HOME``-relative
@@ -271,10 +285,64 @@ async def ensure_opencode_installed(
271
285
  "install_if_missing=False was requested."
272
286
  )
273
287
  assert url is not None # _smart_install_check guarantees
274
- hook_ctx.report_progress(None, "Installing opencode…")
288
+ if report_progress is not None:
289
+ report_progress(None, "Installing opencode…")
275
290
  return await _install_opencode_from_zip(
276
- hook_ctx, url, install_dir=resolved_install_dir,
291
+ host, download, url, install_dir=resolved_install_dir,
292
+ )
293
+
294
+
295
+ def curl_downloader(host: "Host") -> "Callable[[str, str], Awaitable[None]]":
296
+ """Context-free downloader for engine-less callers (verify): fetch a URL
297
+ to a host path via curl on the host itself, vs the engine's child-task
298
+ download_file."""
299
+ async def download(url: str, dest: str) -> None:
300
+ r = await host.run_command(
301
+ f"curl -fsSL {shlex.quote(url)} -o {shlex.quote(dest)}"
302
+ )
303
+ if r.exit_code != 0:
304
+ raise RuntimeError(
305
+ f"curl download failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
306
+ )
307
+ return download
308
+
309
+
310
+ def build_host(ssh, taskdir: str) -> "Host":
311
+ """ssh_config + taskdir -> LocalHost/RemoteHost. Lifted from
312
+ session._build_host so engine-less callers (verify) share it."""
313
+ import os as _os
314
+ from optio_host.host import LocalHost, RemoteHost
315
+
316
+ if ssh is None:
317
+ _os.makedirs(taskdir, exist_ok=True)
318
+ host = LocalHost(taskdir=taskdir)
319
+ _os.makedirs(host.workdir, exist_ok=True)
320
+ return host
321
+ return RemoteHost(ssh_config=ssh, taskdir=taskdir)
322
+
323
+
324
+ async def run_opencode_probe(
325
+ host: "Host",
326
+ *,
327
+ opencode_executable: str,
328
+ model: str,
329
+ prompt: str,
330
+ wrap: "list[str] | None" = None,
331
+ timeout_s: float = 180.0,
332
+ ) -> "tuple[str, int]":
333
+ """Headless one-shot `opencode run` under the per-task isolation env.
334
+ Returns (stdout, exit_code). `wrap` is an argv prefix seam (future
335
+ claustrum fs-isolation). Plain output — the caller's verdict is a
336
+ challenge-answer match on stdout; exit code is diagnostics only."""
337
+ import asyncio as _asyncio
338
+
339
+ argv = [*(wrap or []), opencode_executable, "run", "--model", model, prompt]
340
+ cmd = " ".join(shlex.quote(a) for a in argv)
341
+ result = await _asyncio.wait_for(
342
+ host.run_command(f"bash -lc {shlex.quote(cmd)}", env=_isolation_env(host)),
343
+ timeout=timeout_s,
277
344
  )
345
+ return (result.stdout or "", result.exit_code)
278
346
 
279
347
 
280
348
  async def opencode_version(