Skip to content

Commit d0e22fa

Browse files
committed
feat: add optio-opencode package and opencode-demo reference task
Ship the optio-opencode Python package per the 2026-04-22 design spec: a factory that runs `opencode web` as an optio task — either as a local subprocess or on a remote host over SSH — with opencode's UI embedded in the dashboard via the widget proxy. Consumers pass an OpencodeTaskConfig (system prompt + opencode.json passthrough + SSH config + deliverable callback); the package handles install, launch, port-forwarding, keyword-log-tailing, DONE/ERROR termination, and cleanup. Key components: - `types.py` — SSHConfig, OpencodeTaskConfig, DeliverableCallback alias. - `prompt.py` — coordination-protocol base prompt + `compose_agents_md` composer that prepends it to the consumer's own instructions. - `logparse.py` — regex-based parser for STATUS / DELIVERABLE / DONE / ERROR log lines, with workdir-escape-guarded path validation. - `host.py` — Host Protocol + LocalHost (asyncio.subprocess + aiofiles) + RemoteHost (asyncssh with SFTP, local port forward, tail -F). Both implement detect_target + install_opencode_binary for the shipped- binary path (see below). - `install.py` — OpencodeTarget dataclass + OS / arch / musl / baseline / rosetta detection logic ported from opencode's upstream installer. - `session.py` — run_opencode_session state machine: provision workdir → install opencode → launch → pre-create a shared session via HTTP so multiple dashboard viewers of the same process converge on the same session → register widget upstream with random Basic-Auth password → set widgetData (with `{widgetProxyUrl}` template + iframeSrc pointing at the pre-created session) → spawn tail + fetcher + exit + cancellation watchers → teardown (polite / aggressive). Also includes create_opencode_task factory. Environment-variable configuration (no consumer-code impact): - `OPTIO_OPENCODE_BINARY_DIR` — directory containing opencode's per- target build output (e.g. `opencode-linux-x64/bin/opencode`). When set, optio-opencode probes the target host's triple, resolves the matching binary, and installs it — locally by running it in place, remotely by SFTP-uploading to `~/.local/bin/opencode` with an atomic temp-file + rename and a SHA-256 short-circuit so subsequent runs don't re-upload. Bridges the gap while the iframe-embeddability fixes to opencode are being upstreamed. Demo task: - `packages/optio-demo/src/optio_demo/tasks/opencode.py` registers an `opencode-demo` task wired via `create_opencode_task`. Defaults to local mode; set `OPTIO_OPENCODE_DEMO_SSH_HOST` (plus optional _USER, _KEY_PATH, _PORT) to run the same task over SSH. The consumer prompt has the LLM print the hostname (so remote vs. local is visually obvious), ask for a favorite color, ship a deliverable containing 42 + the color, and append DONE to `./optio.log`. Testing: - 74 Python tests: types, prompt composer, log parser + path validation, host (LocalHost), install target / directory-name, full-stack local integration via a Python test-double for opencode web (happy / error / exit-before-done / invalid-path / non-UTF-8 / callback-raises / cancellation / widget registration), remote integration via linuxserver/openssh-server (skip-on-no-docker). AGENTS.md — root + optio-demo + new package-local cheatsheet describe public API, log-file contract, operating modes, the binary-dir install mechanism, and known MVP limits.
1 parent 89a511c commit d0e22fa

28 files changed

Lines changed: 2812 additions & 0 deletions

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ must stay consistent with the package-level files.
4343
| 2 — Remote control | `optio-core[redis]` | Python | `pip install optio-core[redis]` |
4444
| 3 — REST API | `optio-api` | TypeScript | `npm install optio-api optio-contracts` |
4545
| 4 — React UI | `optio-ui` | TypeScript | `npm install optio-ui optio-contracts @tanstack/react-query react-i18next antd` |
46+
| 1+ — Opencode runner | `optio-opencode` | Python | workspace; runs `opencode web` as an optio task (local subprocess or remote via SSH) |
4647

4748
Dependencies: Python requires `motor>=3.3.0`, `apscheduler>=4.0.0a5`, `quaestor`. Redis support: `redis>=5.0.0` (optional extra). TypeScript API requires `mongodb`, `ioredis`, `@ts-rest/core`. UI requires React 19+, Ant Design 5+.
4849

@@ -343,6 +344,28 @@ Collection: `{prefix}_processes`
343344

344345
---
345346

347+
## Python: optio-opencode
348+
349+
Run `opencode web` as an optio task. Public API:
350+
351+
```python
352+
from optio_opencode import create_opencode_task, OpencodeTaskConfig, SSHConfig
353+
354+
task = create_opencode_task(
355+
process_id="my-task", name="My task",
356+
config=OpencodeTaskConfig(
357+
consumer_instructions="...",
358+
opencode_config={...}, # passthrough to opencode.json
359+
ssh=None, # None = local subprocess
360+
on_deliverable=cb,
361+
),
362+
)
363+
```
364+
365+
Full details: `packages/optio-opencode/AGENTS.md`.
366+
367+
---
368+
346369
## TypeScript: optio-contracts
347370

348371
Package: `optio-contracts`

packages/optio-demo/AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,11 @@ Environment variables (all optional, shown with defaults):
8181
- `MONGODB_URL``mongodb://localhost:27017/optio-demo`
8282
- `REDIS_URL``redis://localhost:6379`
8383
- `OPTIO_PREFIX``optio`
84+
85+
## Opencode demo
86+
87+
Task `opencode-demo` runs a short local opencode session that asks the
88+
human for a favorite color and ships a deliverable containing the
89+
color and the number 42. Reference consumer for `optio-opencode`;
90+
exercises the full iframe + proxy + log-tail stack. Requires opencode
91+
to be installed and authenticated on the developer's machine.

packages/optio-demo/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
1111
dependencies = [
1212
"optio-core[redis]",
1313
"marimo>=0.9",
14+
"optio-opencode",
1415
]
1516

1617
[tool.setuptools.packages.find]

packages/optio-demo/src/optio_demo/tasks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from optio_demo.tasks.festival import get_tasks as festival_tasks
99
from optio_demo.tasks.wakeup import get_tasks as wakeup_tasks
1010
from optio_demo.tasks.marimo import get_tasks as marimo_tasks
11+
from optio_demo.tasks.opencode import get_tasks as opencode_tasks
1112

1213

1314
async def get_task_definitions(services: dict) -> list[TaskInstance]:
@@ -18,4 +19,5 @@ async def get_task_definitions(services: dict) -> list[TaskInstance]:
1819
*festival_tasks(),
1920
*wakeup_tasks(),
2021
*marimo_tasks(),
22+
*opencode_tasks(),
2123
]
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Reference demo task for optio-opencode.
2+
3+
Defaults to local mode (per the design spec, Section 10); set the
4+
``OPTIO_OPENCODE_DEMO_SSH_HOST`` environment variable to run the same
5+
task via SSH on a remote host. Relevant env vars (all optional except
6+
``_HOST``):
7+
8+
- ``OPTIO_OPENCODE_DEMO_SSH_HOST`` — enables remote mode.
9+
- ``OPTIO_OPENCODE_DEMO_SSH_USER`` — default: ``$USER`` on the worker.
10+
- ``OPTIO_OPENCODE_DEMO_SSH_KEY_PATH`` — default: ``~/.ssh/id_ed25519``.
11+
- ``OPTIO_OPENCODE_DEMO_SSH_PORT`` — default: ``22``.
12+
13+
The consumer prompt is the hostname-and-color-and-42 prompt described
14+
in the spec. The deliverable callback surfaces the file contents back
15+
into the optio log channel so the human can visually confirm
16+
round-trip success.
17+
"""
18+
19+
import os
20+
21+
from optio_core.context import ProcessContext
22+
from optio_core.models import TaskInstance
23+
from optio_opencode import OpencodeTaskConfig, SSHConfig, create_opencode_task
24+
25+
26+
CONSUMER_PROMPT = (
27+
"Tell me the hostname of the system you are running on. "
28+
"Then ask the human about their favorite color, then ship a "
29+
"deliverable containing the number 42 and the designated color. "
30+
"Then signal completion by appending a `DONE` line to the "
31+
"`./optio.log` file (writing `DONE` in the chat has no effect — "
32+
"it must go into that file)."
33+
)
34+
35+
36+
def _resolve_ssh_config() -> SSHConfig | None:
37+
"""Build an SSHConfig from env vars, or None for local mode."""
38+
host = os.environ.get("OPTIO_OPENCODE_DEMO_SSH_HOST")
39+
if not host:
40+
return None
41+
user = (
42+
os.environ.get("OPTIO_OPENCODE_DEMO_SSH_USER")
43+
or os.environ.get("USER")
44+
or "root"
45+
)
46+
key_path = os.environ.get(
47+
"OPTIO_OPENCODE_DEMO_SSH_KEY_PATH",
48+
os.path.expanduser("~/.ssh/id_ed25519"),
49+
)
50+
port_raw = os.environ.get("OPTIO_OPENCODE_DEMO_SSH_PORT", "22")
51+
try:
52+
port = int(port_raw)
53+
except ValueError:
54+
raise RuntimeError(
55+
f"OPTIO_OPENCODE_DEMO_SSH_PORT must be an integer, got {port_raw!r}"
56+
)
57+
return SSHConfig(host=host, user=user, key_path=key_path, port=port)
58+
59+
60+
def _make_on_deliverable(ctx: ProcessContext):
61+
async def _cb(path: str, text: str) -> None:
62+
ctx.report_progress(
63+
None,
64+
f"deliverable {os.path.basename(path)}: {text[:200]}",
65+
)
66+
return _cb
67+
68+
69+
def get_tasks() -> list[TaskInstance]:
70+
async def _execute(ctx: ProcessContext) -> None:
71+
config = OpencodeTaskConfig(
72+
consumer_instructions=CONSUMER_PROMPT,
73+
opencode_config={},
74+
# Resolved at execution time so env-var changes between
75+
# worker restarts are picked up without reinstalling the task.
76+
ssh=_resolve_ssh_config(),
77+
on_deliverable=_make_on_deliverable(ctx),
78+
)
79+
inner = create_opencode_task(
80+
process_id="opencode-demo-inner",
81+
name="opencode demo inner",
82+
config=config,
83+
)
84+
await inner.execute(ctx)
85+
86+
return [
87+
TaskInstance(
88+
execute=_execute,
89+
process_id="opencode-demo",
90+
name="Opencode demo",
91+
description=(
92+
"Opencode session asking for a color and shipping a "
93+
"deliverable. Set OPTIO_OPENCODE_DEMO_SSH_HOST to run "
94+
"remotely; otherwise runs locally."
95+
),
96+
ui_widget="iframe",
97+
)
98+
]

packages/optio-opencode/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
tests/ssh-keys/
2+
tests/scenario.txt
3+
*.egg-info/
4+
__pycache__/
5+
.pytest_cache/

packages/optio-opencode/AGENTS.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# optio-opencode — Agent Cheatsheet
2+
3+
Run `opencode web` as an optio task. Either a local subprocess or a
4+
remote host reached over SSH; the optio dashboard embeds opencode's UI
5+
via the widget proxy.
6+
7+
Full design: `docs/2026-04-22-optio-opencode-design.md`.
8+
9+
## Public API
10+
11+
```python
12+
from optio_opencode import (
13+
create_opencode_task,
14+
OpencodeTaskConfig,
15+
SSHConfig,
16+
DeliverableCallback,
17+
)
18+
19+
async def on_file(path: str, text: str) -> None:
20+
...
21+
22+
task = create_opencode_task(
23+
process_id="my-task",
24+
name="My task",
25+
config=OpencodeTaskConfig(
26+
consumer_instructions="...", # prepended with optio-opencode's base prompt
27+
opencode_config={"model": "..."}, # passthrough to opencode.json
28+
ssh=None, # None = local subprocess
29+
on_deliverable=on_file,
30+
install_if_missing=True,
31+
),
32+
description="optional",
33+
)
34+
```
35+
36+
The returned `TaskInstance` has `ui_widget="iframe"` baked in.
37+
38+
## Log-file contract
39+
40+
optio-opencode tells opencode (via AGENTS.md) to append one line per
41+
event to `./optio.log` with keywords:
42+
43+
- `STATUS: [N%] <msg>`
44+
- `DELIVERABLE: <path>`
45+
- `DONE[: summary]`
46+
- `ERROR[: message]`
47+
48+
The Python side tails the file, dispatches by keyword, SFTPs
49+
deliverable files back, decodes UTF-8, and invokes `on_deliverable`.
50+
DONE / ERROR terminate the session; other keywords flow through as
51+
progress updates / log entries.
52+
53+
## Operating modes
54+
55+
- **Local**: `asyncio.create_subprocess_exec("opencode", "web", ...)`.
56+
Workdir is a `tempfile.mkdtemp`. Expects opencode pre-installed
57+
(or use `OPTIO_OPENCODE_BINARY_DIR`, below).
58+
- **Remote**: single asyncssh connection multiplexes exec (install,
59+
launch, `tail -F`, teardown), SFTP, and local port forward. Workdir
60+
is `/tmp/optio-opencode-<uuid>/` on the remote.
61+
62+
## Shipping a specific opencode binary
63+
64+
Set `OPTIO_OPENCODE_BINARY_DIR` in the worker's environment to a
65+
directory matching opencode's build layout (i.e. containing per-target
66+
subdirs like `opencode-linux-x64/bin/opencode`, `opencode-darwin-arm64/
67+
bin/opencode`, `opencode-linux-x64-baseline-musl/bin/opencode`, …). When
68+
set, optio-opencode:
69+
70+
1. Detects the target host's OS / arch / libc / AVX2 via `uname`, `ldd`
71+
and `/proc/cpuinfo` (detection mirrors opencode's upstream install
72+
script so the subdir name is the one opencode's build would emit).
73+
2. Resolves the matching binary inside `OPTIO_OPENCODE_BINARY_DIR`.
74+
3. **Local mode:** runs that binary directly (bypasses any `opencode`
75+
on PATH).
76+
4. **Remote mode:** SFTP-uploads the binary to `~/.local/bin/opencode`
77+
on the remote host (atomic via temp-file + rename), skipping the
78+
upload when the existing file's SHA-256 already matches. Launches
79+
via the absolute path, so the user doesn't need `~/.local/bin` on
80+
their PATH for optio-opencode to work (though they'll benefit from
81+
having it on PATH for later manual invocations).
82+
83+
When the env var is unset, behavior falls back to the previous scheme:
84+
local mode expects `opencode` on PATH, remote mode runs the upstream
85+
curl installer if `opencode` is missing.
86+
87+
This is a bridge feature for shipping an iframe-embeddability fork of
88+
opencode until those fixes land upstream; once upstream ships, the env
89+
var's only remaining use is pinning to a specific build.
90+
91+
## Testing
92+
93+
Unit + local integration: `pytest tests/` (needs MongoDB via Docker).
94+
95+
Remote integration: `pytest tests/test_session_remote.py` — skips on
96+
machines without Docker; brings up `linuxserver/openssh-server` on
97+
`127.0.0.1:22222`.
98+
99+
## Known limits (MVP)
100+
101+
- SSH auth is key-path only; no agent, inline keys, or passwords.
102+
- No host-key verification (`known_hosts=None`).
103+
- Fail-fast on SSH drop; no reconnect.
104+
- Text deliverables only (non-UTF-8 files are skipped, not delivered).
105+
- Grace-period sensitive: teardown can exceed 5 s; host apps running
106+
slow / remote sessions should call
107+
`optio_core.shutdown(grace_seconds=30)`.
108+
109+
Deferred items live in spec Section 11.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[build-system]
2+
requires = ["setuptools>=68.0", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "optio-opencode"
7+
version = "0.1.0"
8+
description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9+
license = "Apache-2.0"
10+
requires-python = ">=3.11"
11+
dependencies = [
12+
"optio-core",
13+
"asyncssh>=2.14",
14+
"aiofiles>=23.0",
15+
]
16+
17+
[project.optional-dependencies]
18+
dev = [
19+
"pytest>=8.0",
20+
"pytest-asyncio>=0.23",
21+
]
22+
23+
[tool.setuptools.packages.find]
24+
where = ["src"]
25+
26+
[tool.pytest.ini_options]
27+
asyncio_mode = "auto"
28+
testpaths = ["tests"]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""optio-opencode — run opencode web as an optio task."""
2+
3+
import logging as _logging
4+
5+
from optio_opencode.session import create_opencode_task, run_opencode_session
6+
from optio_opencode.types import (
7+
DeliverableCallback,
8+
OpencodeTaskConfig,
9+
SSHConfig,
10+
)
11+
12+
# asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
13+
# connection...", "Received channel close", etc.) that flood the worker's
14+
# stdout once an SSH-backed opencode session starts. Quiet it down by
15+
# default. Consumers that want the verbose trace can override:
16+
#
17+
# logging.getLogger("asyncssh").setLevel(logging.INFO)
18+
_logging.getLogger("asyncssh").setLevel(_logging.WARNING)
19+
20+
__all__ = [
21+
"create_opencode_task",
22+
"run_opencode_session",
23+
"DeliverableCallback",
24+
"OpencodeTaskConfig",
25+
"SSHConfig",
26+
]

0 commit comments

Comments
 (0)