diff --git a/archivebox/opencode/templates/opencode/agent.html b/archivebox/opencode/templates/opencode/agent.html
index e6f41a67..8a252295 100644
--- a/archivebox/opencode/templates/opencode/agent.html
+++ b/archivebox/opencode/templates/opencode/agent.html
@@ -206,78 +206,6 @@
});
})();
-
{% endif %}
{% endblock %}
diff --git a/archivebox/opencode/urls.py b/archivebox/opencode/urls.py
index bf8fad73..1b073502 100644
--- a/archivebox/opencode/urls.py
+++ b/archivebox/opencode/urls.py
@@ -1,11 +1,10 @@
from django.urls import path, re_path
-from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view
+from archivebox.opencode.views import agent_view, opencode_proxy_view
urlpatterns = [
path("", agent_view, name="opencode-agent"),
- path("opencode/_archivebox/health", agent_health_view, name="opencode-health"),
re_path(
r"^opencode(?:/(?P.*))?$",
opencode_proxy_view,
diff --git a/archivebox/opencode/views.py b/archivebox/opencode/views.py
index 49ba0de1..f2913195 100644
--- a/archivebox/opencode/views.py
+++ b/archivebox/opencode/views.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import atexit
import base64
+import json
import logging
import os
import re
@@ -26,7 +27,6 @@ from django.http import (
HttpRequest,
HttpResponse,
HttpResponseForbidden,
- JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
@@ -42,6 +42,7 @@ _PROXY_PREFIX = "/admin/agent/opencode"
_PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/")
_PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/")
_CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json")
+_DEFAULT_MODEL = "opencode/big-pickle"
_TEXT_CONTENT_TYPES = (
"text/",
@@ -219,7 +220,7 @@ def _settings(config: dict) -> dict:
str(_config_value(config, "OPENCODE_STATE_DIR", default_data_dir / "opencode")),
).expanduser()
workdir = Path(
- str(_config_value(config, "OPENCODE_WORKDIR", opencode_dir / "workdir")),
+ str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)),
).expanduser()
binary = str(_config_value(config, "OPENCODE_BINARY", "opencode"))
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 120))
@@ -321,6 +322,21 @@ def _ensure_project_files(settings: dict) -> None:
opencode_skill_path.unlink()
opencode_skill_path.symlink_to(editable_skill_path)
+ opencode_config_path = settings["config_home"] / "opencode" / "opencode.jsonc"
+ default_config = {
+ "$schema": "https://opencode.ai/config.json",
+ "model": _DEFAULT_MODEL,
+ }
+ if not opencode_config_path.exists():
+ opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n")
+ else:
+ try:
+ existing_config = json.loads(opencode_config_path.read_text())
+ except (OSError, ValueError):
+ existing_config = None
+ if isinstance(existing_config, dict) and set(existing_config) <= {"$schema"}:
+ opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n")
+
def _ensure_default_session(settings: dict) -> str:
workdir = settings["workdir"].resolve()
@@ -393,19 +409,6 @@ def _health(settings: dict, timeout: float = 2) -> bool:
return False
-def _opencode_version(settings: dict) -> str:
- try:
- response = requests.get(
- f"{settings['origin']}/global/health",
- timeout=2,
- )
- response.raise_for_status()
- data = response.json()
- return str(data.get("version") or "") if isinstance(data, dict) else ""
- except (requests.RequestException, ValueError):
- return ""
-
-
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS, _PROCESS_READY
started_process: subprocess.Popen | None = None
@@ -539,16 +542,13 @@ def agent_view(request: HttpRequest):
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
recent_session_id = ""
- opencode_version = ""
if ok:
try:
with _SESSION_LOCK:
recent_session_id = _ensure_default_session(settings)
- opencode_version = _opencode_version(settings)
- if not opencode_version:
- ok = False
- error = "OpenCode health check did not report its version."
except (requests.RequestException, RuntimeError, ValueError) as err:
+ if isinstance(err, requests.RequestException) and _owned_process_ready():
+ _stop_owned_process()
ok = False
error = f"OpenCode project initialization failed: {err}"
from archivebox.core.admin_site import archivebox_admin
@@ -565,7 +565,6 @@ def agent_view(request: HttpRequest):
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"proxy_prefix": _PROXY_PREFIX,
- "opencode_version": opencode_version,
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
@@ -577,23 +576,6 @@ def agent_view(request: HttpRequest):
)
-def agent_health_view(request: HttpRequest):
- config = _machine_config()
- _require_enabled(config)
- auth_response = _require_superuser(request)
- if auth_response:
- return auth_response
-
- version = _opencode_version(_settings(config))
- healthy = bool(version)
- response = JsonResponse(
- {"healthy": healthy, "version": version},
- status=200 if healthy else 503,
- )
- response.headers["Cache-Control"] = "no-store"
- return response
-
-
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)
diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py
index 0d81c15d..ff639958 100644
--- a/archivebox/tests/test_opencode_agent.py
+++ b/archivebox/tests/test_opencode_agent.py
@@ -1,4 +1,5 @@
import asyncio
+import json
import os
import signal
import socket
@@ -189,14 +190,8 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert b'id="header"' in response.content
assert b'id="progress-monitor"' in response.content
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
- assert response.context["opencode_version"]
- assert b"const healthUrl" in response.content
- assert b"/admin/agent/opencode/global/health" in response.content
- assert b"/admin/agent/opencode/_archivebox/health" in response.content
- assert b'redirect: "manual"' in response.content
- assert b"let waking = false" in response.content
- assert b"wake();" in response.content
- assert b"frame.contentWindow.location.reload()" in response.content
+ assert b"/_archivebox/health" not in response.content
+ assert b"window.setInterval(check, 3000)" not in response.content
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'"
@@ -210,21 +205,6 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
-def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode):
- from archivebox.opencode import views
-
- views._stop_owned_process()
- response = admin_client.get(
- "/admin/agent/opencode/_archivebox/health",
- HTTP_HOST=ADMIN_TEST_HOST,
- )
-
- assert response.status_code == 503
- assert response.json() == {"healthy": False, "version": ""}
- assert response.headers["Cache-Control"] == "no-store"
- assert views._PROCESS is None
-
-
def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
encoded_workdir = quote(workdir)
@@ -422,8 +402,15 @@ def test_opencode_starts_with_isolated_state(live_opencode):
)
project.raise_for_status()
+ config = requests.get(
+ f"{live_opencode.settings['origin']}/global/config",
+ timeout=live_opencode.settings["timeout"],
+ )
+ config.raise_for_status()
+
assert Path(live_opencode.settings["workdir"]).resolve() == Path(workdir)
assert Path(str(project.json()["worktree"])).resolve() == Path(workdir)
+ assert config.json()["model"] == "opencode/big-pickle"
assert live_opencode.process.poll() is None
assert (live_opencode.config.data_dir / ".git").is_dir()
assert (state_dir / "data" / "opencode" / "opencode.db").is_file()
@@ -455,15 +442,57 @@ def test_opencode_state_dir_is_separate_from_workdir(tmp_path):
assert loaded_skill.is_symlink()
assert loaded_skill.resolve() == editable_skill.resolve()
assert f"ArchiveBox collection directory: {settings['archivebox_data_dir']}" in editable_skill.read_text()
+ assert json.loads((state_dir / "config" / "opencode" / "opencode.jsonc").read_text())["model"] == "opencode/big-pickle"
-def test_opencode_default_workdir_does_not_scan_the_collection():
+def test_opencode_preserves_existing_config(tmp_path):
+ from archivebox.opencode import views
+
+ state_dir = tmp_path / "state"
+ config_path = state_dir / "config" / "opencode" / "opencode.jsonc"
+ config_path.parent.mkdir(parents=True)
+ existing_config = '{\n // Keep the administrator-selected model.\n "model": "anthropic/claude-sonnet-4-5"\n}\n'
+ config_path.write_text(existing_config)
+
+ views._ensure_project_files(
+ views._settings(
+ {
+ "OPENCODE_WORKDIR": str(tmp_path / "workdir"),
+ "OPENCODE_STATE_DIR": str(state_dir),
+ },
+ ),
+ )
+
+ assert config_path.read_text() == existing_config
+
+
+def test_opencode_adds_default_model_to_schema_only_config(tmp_path):
+ from archivebox.opencode import views
+
+ state_dir = tmp_path / "state"
+ config_path = state_dir / "config" / "opencode" / "opencode.jsonc"
+ config_path.parent.mkdir(parents=True)
+ config_path.write_text('{"$schema": "https://opencode.ai/config.json"}\n')
+
+ views._ensure_project_files(
+ views._settings(
+ {
+ "OPENCODE_WORKDIR": str(tmp_path / "workdir"),
+ "OPENCODE_STATE_DIR": str(state_dir),
+ },
+ ),
+ )
+
+ assert json.loads(config_path.read_text())["model"] == "opencode/big-pickle"
+
+
+def test_opencode_defaults_to_the_archivebox_collection():
from archivebox.opencode import views
settings = views._settings({})
assert settings["opencode_dir"] == settings["archivebox_data_dir"] / "opencode"
- assert settings["workdir"] == settings["opencode_dir"] / "workdir"
+ assert settings["workdir"] == settings["archivebox_data_dir"]
assert settings["timeout"] == 120