feat(worker): OpenCode harness for Submatter Phase B
Some checks are pending
CI / Lint, type-check, test (push) Waiting to run
Deploy homelab (CT 116) / Deploy to CT 116 (push) Waiting to run

Add WORKER_SESSION_TYPE=opencode with a headless OpenCode session adapter,
compose profile, and OmniRoute plugin config. Prefer OpenCode over the
unused OpenHands agent-server path; fix missing yaml runtime dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-09-12 12:47:56 -05:00
parent 9bde5960bf
commit 9572eac3eb
16 changed files with 584 additions and 26 deletions

View file

@ -43,15 +43,24 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
#
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
# Phase B — coding harness (optional)
# Preferred: OpenCode (COMPOSE_PROFILES=opencode)
# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running
# OPENCODE_SERVER_URL=http://opencode:4096
# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container
# OPENCODE_SERVER_USERNAME=opencode
# OPENCODE_PROVIDER_ID=omniroute
# WORKER_OPENCODE_WORKSPACE=/workspace/repo
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=
# OMNIROUTE_MODEL=auto/coding
#
# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server)
# WORKER_SESSION_TYPE=openhands
# AGENT_SERVER_URL=http://agent-server:8001
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=
# OMNIROUTE_MODEL=auto/coding
# ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud

View file

@ -18,6 +18,7 @@
"ai": "^4.3.16",
"drizzle-orm": "^0.38.0",
"ioredis": "^5.4.2",
"yaml": "^2.7.0",
"zod": "^3.24.0"
},
"devDependencies": {

View file

@ -33,9 +33,14 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"),
/** Session type: "minimal" (default, in-process) or "openhands" (agent-server). */
/**
* Session type:
* - minimal (default) — in-process tool loop
* - opencode — headless `opencode serve` on CT 116 (preferred harness)
* - openhands — legacy agent-server (optional profile; not the target path)
*/
WORKER_SESSION_TYPE: z
.enum(["minimal", "openhands"])
.enum(["minimal", "opencode", "openhands"])
.optional()
.default("minimal"),
/** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */
@ -53,6 +58,30 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => value?.trim() || ""),
/** OpenCode serve/web base URL. Docker: http://opencode:4096 */
OPENCODE_SERVER_URL: z
.string()
.optional()
.transform((value) => value?.trim() || "http://opencode:4096"),
/** Must match OPENCODE_SERVER_PASSWORD on the opencode container when set. */
OPENCODE_SERVER_PASSWORD: z
.string()
.optional()
.transform((value) => value?.trim() || ""),
OPENCODE_SERVER_USERNAME: z
.string()
.optional()
.transform((value) => value?.trim() || "opencode"),
/** Provider id registered by @omniroute/opencode-plugin (default omniroute). */
OPENCODE_PROVIDER_ID: z
.string()
.optional()
.transform((value) => value?.trim() || "omniroute"),
/** Workspace path OpenCode should treat as the project root (shared volume). */
WORKER_OPENCODE_WORKSPACE: z
.string()
.optional()
.transform((value) => value?.trim() || "/workspace/repo"),
});
export type WorkerEnv = z.infer<typeof envSchema>;

View file

@ -5,6 +5,7 @@ import type {
WorkerJobContext,
} from "@tasks/shared/worker";
import { createMinimalSession } from "../session/minimal-session.js";
import { createOpenCodeSession, type OpenCodeConfig } from "../session/opencode-session.js";
import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js";
import { createRemoteAgUiExecutor } from "./remote-ag-ui.js";
@ -82,6 +83,33 @@ function buildOpenHandsExecutor(config: OpenHandsConfig): WorkerExecutor {
};
}
function buildOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor {
return {
id: "opencode",
harnessType: "minimal" as HarnessType,
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
const session = await createOpenCodeSession(ctx, {
config,
resolvedModel: opts.resolvedModel as never,
roleId: ctx.roleId,
briefId: ctx.briefId,
briefTitle: opts.convoyTitle,
workflowPrompt: opts.workflowPrompt,
briefAcceptance: opts.briefAcceptance,
briefFiles: opts.briefFiles,
maxSteps: opts.maxSteps,
});
const convoyTitle = opts.convoyTitle ?? "unknown";
const result = await session.run(opts.buildPrompt(ctx, convoyTitle));
return {
summary: result.summary,
toolCalls: result.toolCalls,
tokensTotal: result.tokensTotal,
};
},
};
}
export type HarnessExecutorConfig = {
harnessType: HarnessType;
endpointUrl?: string | null;
@ -117,3 +145,15 @@ export function createOpenHandsExecutor(
}
return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot });
}
/**
* Create an executor for headless OpenCode (`opencode serve`).
* Password is optional (local/dev); production should set OPENCODE_SERVER_PASSWORD.
*/
export function createOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor | null {
if (!config.serverUrl) {
console.warn("[worker] OpenCode executor unavailable: missing OPENCODE_SERVER_URL");
return null;
}
return buildOpenCodeExecutor(config);
}

View file

@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import { createOpenCodeExecutor } from "./index.js";
describe("createOpenCodeExecutor", () => {
it("returns null when serverUrl is empty", () => {
const exec = createOpenCodeExecutor({
serverUrl: "",
serverPassword: "",
serverUsername: "opencode",
repoRoot: "/workspace/repo",
providerId: "omniroute",
});
expect(exec).toBeNull();
});
it("returns an opencode executor with the expected id", () => {
const exec = createOpenCodeExecutor({
serverUrl: "http://opencode:4096",
serverPassword: "secret",
serverUsername: "opencode",
repoRoot: "/workspace/repo",
providerId: "omniroute",
});
expect(exec).not.toBeNull();
expect(exec?.id).toBe("opencode");
});
it("creates a session and posts a message with basic auth", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "ses_1" }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
info: { role: "assistant" },
parts: [{ type: "text", text: "done" }, { type: "tool", tool: "bash" }],
}),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchImpl);
const exec = createOpenCodeExecutor({
serverUrl: "http://opencode:4096",
serverPassword: "secret",
serverUsername: "opencode",
repoRoot: "/workspace/repo",
providerId: "omniroute",
});
expect(exec).not.toBeNull();
if (!exec) return;
const result = await exec.run(
{
workspaceId: "w",
convoyId: "c",
roleId: "role-implementer",
briefId: "b",
repoRoot: "/workspace/repo",
},
{
resolvedModel: {
modelId: "omniroute/auto/coding",
providerType: "openai",
alias: "auto/coding",
},
resolvedAlias: "auto/coding",
rolePromptPath: "/tmp/role.md",
maxSteps: 5,
buildPrompt: () => "Implement the thing",
convoyTitle: "test",
},
);
expect(result.summary).toBe("done");
expect(result.toolCalls).toBe(1);
expect(fetchImpl).toHaveBeenCalledTimes(2);
const [, createInit] = fetchImpl.mock.calls[0]!;
expect((createInit as RequestInit).headers).toMatchObject({
Authorization: expect.stringMatching(/^Basic /),
});
const [msgUrl, msgInit] = fetchImpl.mock.calls[1]!;
expect(msgUrl).toBe("http://opencode:4096/session/ses_1/message");
const body = JSON.parse(String((msgInit as RequestInit).body));
expect(body.model).toEqual({ providerID: "omniroute", modelID: "auto/coding" });
vi.unstubAllGlobals();
});
});

View file

@ -91,6 +91,11 @@ const env = {
AGENT_SERVER_URL: "http://agent-server:8001",
WORKER_OPENHANDS_WORKSPACE: "/workspace/repo",
SESSION_API_KEY: "",
OPENCODE_SERVER_URL: "http://opencode:4096",
OPENCODE_SERVER_PASSWORD: "",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_PROVIDER_ID: "omniroute",
WORKER_OPENCODE_WORKSPACE: "/workspace/repo",
REDIS_URL: "redis://localhost:6379",
};

View file

@ -30,7 +30,7 @@ import {
} from "./git/worktree.js";
import { openDraftPR } from "./git/pr.js";
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
import { getExecutor, createOpenCodeExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
import { resolveHarnessForRoleDb } from "./db/harness.js";
import { resolveRepoRoot } from "./lib/repo-root.js";
import {
@ -255,10 +255,31 @@ export async function handleOrchestratorJob(
}
: undefined;
// Resolve the executor from the registry based on the harness type
// When WORKER_SESSION_TYPE=openhands, route to OpenHands agent-server executor
// Resolve the executor from the registry based on the harness type.
// WORKER_SESSION_TYPE=opencode → headless OpenCode (preferred).
// WORKER_SESSION_TYPE=openhands → legacy OpenHands agent-server.
let executor;
if (env.WORKER_SESSION_TYPE === "openhands") {
if (env.WORKER_SESSION_TYPE === "opencode") {
const ocExecutor = createOpenCodeExecutor({
serverUrl: env.OPENCODE_SERVER_URL,
serverPassword: env.OPENCODE_SERVER_PASSWORD,
serverUsername: env.OPENCODE_SERVER_USERNAME,
repoRoot: env.WORKER_OPENCODE_WORKSPACE,
providerId: env.OPENCODE_PROVIDER_ID,
});
if (ocExecutor) {
executor = ocExecutor;
console.info(`[worker] using OpenCode executor server=${env.OPENCODE_SERVER_URL}`);
} else {
console.warn("[worker] OpenCode executor unavailable, falling back to minimal");
const harnessExecutorConfig: HarnessExecutorConfig = {
harnessType: harness.harnessType,
endpointUrl: harness.endpointUrl,
authEnvVar: harness.authEnvVar,
};
executor = getExecutor(harnessExecutorConfig);
}
} else if (env.WORKER_SESSION_TYPE === "openhands") {
const ohExecutor = createOpenHandsExecutor(
env.AGENT_SERVER_URL,
env.SESSION_API_KEY,

View file

@ -0,0 +1,175 @@
import type { ResolvedModel } from "@tasks/shared/model-routing";
import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker";
import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js";
/**
* Configuration for a headless OpenCode server (`opencode serve` / `opencode web`).
* Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server.
*/
export type OpenCodeConfig = {
serverUrl: string;
/** Basic-auth password; empty = no Authorization header. */
serverPassword: string;
/** Basic-auth username (OpenCode default: opencode). */
serverUsername: string;
/** Repo root to scan for .agents/skills/ and to describe in the prompt. */
repoRoot: string;
/** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */
providerId: string;
};
export type OpenCodeSessionOptions = {
config: OpenCodeConfig;
resolvedModel: ResolvedModel;
roleId: string;
briefId?: string;
briefTitle?: string;
workflowPrompt?: string;
briefAcceptance?: string;
briefFiles?: string[];
maxSteps?: number;
};
type SessionCreateResponse = { id: string; title?: string };
type MessageResponse = {
info?: { role?: string; error?: { name?: string; message?: string } };
parts?: Array<{ type?: string; text?: string; tool?: string }>;
};
function basicAuthHeader(username: string, password: string): string {
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
return `Basic ${token}`;
}
function stripProviderPrefix(modelId: string, providerId: string): string {
const prefix = `${providerId}/`;
return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
}
/**
* Create an OpenCode session that drives a remote `opencode serve` over HTTP.
* Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin,
* no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade.
*/
export async function createOpenCodeSession(
context: WorkerJobContext,
options: OpenCodeSessionOptions,
): Promise<WorkerSession> {
const {
config,
resolvedModel,
roleId,
briefId,
briefTitle,
workflowPrompt,
briefAcceptance,
briefFiles,
} = options;
const apiBase = config.serverUrl.replace(/\/$/, "");
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.serverPassword) {
headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword);
}
let skillContext = "";
try {
const skills = await loadAgentSkills(config.repoRoot);
skillContext = buildSkillFullContext(skills);
} catch (e) {
console.warn("[opencode] skill loading failed, continuing without skills:", e);
}
const systemParts: string[] = [
`You are an AI agent executing a brief for convoy "${context.convoyId}".`,
`Role: ${roleId}`,
briefId ? `Brief ID: ${briefId}` : "",
briefTitle ? `Brief: ${briefTitle}` : "",
`Working directory: ${config.repoRoot}`,
workflowPrompt ? `\n## Task\n${workflowPrompt}` : "",
briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "",
briefFiles?.length
? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}`
: "",
skillContext || "",
"\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.",
].filter(Boolean);
const systemPrompt = systemParts.join("\n");
const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId);
async function createSession(title: string): Promise<string> {
const res = await fetch(`${apiBase}/session`, {
method: "POST",
headers,
body: JSON.stringify({ title }),
});
if (!res.ok) {
throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`);
}
const data = (await res.json()) as SessionCreateResponse;
if (!data.id) throw new Error("OpenCode session create returned no id");
return data.id;
}
async function sendPrompt(sessionId: string, prompt: string): Promise<{
summary: string;
toolCalls: number;
}> {
const res = await fetch(`${apiBase}/session/${sessionId}/message`, {
method: "POST",
headers,
body: JSON.stringify({
model: { providerID: config.providerId, modelID: modelId },
system: systemPrompt,
parts: [{ type: "text", text: prompt }],
}),
});
if (!res.ok) {
throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`);
}
const data = (await res.json()) as MessageResponse;
if (data.info?.error) {
throw new Error(
`OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`,
);
}
const textParts = (data.parts ?? [])
.filter((p) => p.type === "text" && p.text)
.map((p) => p.text as string);
const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length;
return {
summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.",
toolCalls,
};
}
return {
context,
async run(prompt: string) {
console.info(
`[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`,
);
try {
const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`;
const sessionId = await createSession(title);
console.info(`[opencode] session started id=${sessionId}`);
const result = await sendPrompt(sessionId, prompt);
console.info(
`[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`,
);
return result;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[opencode] session failed:`, msg);
return {
summary: `OpenCode session failed: ${msg}`,
toolCalls: 0,
};
}
},
};
}

View file

@ -1,7 +1,9 @@
# Echodo production stack — shared CT 102 Postgres/Redis, multi-service build.
#
# Phase A (default): web, collab, mcp, orchestrator, worker
# Phase B (optional): OpenHands agent-server — enable with profile `agent-server`
# Phase B (optional coding harness):
# Preferred: OpenCode — COMPOSE_PROFILES=opencode, WORKER_SESSION_TYPE=opencode
# Legacy: OpenHands — COMPOSE_PROFILES=agent-server, WORKER_SESSION_TYPE=openhands
#
# Deploy targets:
# - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root
@ -10,13 +12,18 @@
# Domains (Dokploy UI or CT 100 Traefik):
# web → echodo.stillwell.cloud (port 3000)
# collab → collab.echodo.stillwell.cloud (port 1234)
# opencode (optional) → opencode.stillwell.cloud (port 4096)
#
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
# bash scripts/build-prod.sh
# Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh
#
# Phase B:
# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d
# Phase B OpenCode:
# export COMPOSE_PROFILES=opencode
# Set WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_API_KEY / BASE_URL.
#
# Phase B OpenHands (legacy):
# export COMPOSE_PROFILES=agent-server
# Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY.
services:
@ -179,6 +186,11 @@ services:
- WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}
- AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001}
- SESSION_API_KEY=${SESSION_API_KEY:-}
- OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-http://opencode:4096}
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
- OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID:-omniroute}
- WORKER_OPENCODE_WORKSPACE=${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
@ -200,6 +212,52 @@ services:
networks:
- echodo
opencode:
profiles: ["opencode"]
image: ghcr.io/anomalyco/opencode:latest
container_name: submatter-opencode
restart: unless-stopped
working_dir: /workspace/repo
entrypoint: ["/bin/sh", "/config/entrypoint.sh"]
command:
[
"opencode",
"serve",
"--hostname",
"0.0.0.0",
"--port",
"4096",
]
environment:
- HOME=/data
- XDG_CONFIG_HOME=/data/.config
- XDG_DATA_HOME=/data/.local/share
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
- OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY}
volumes:
- echodo-workspace:/workspace
- opencode-data:/data
- ./docker/opencode/opencode.json:/config/opencode.json:ro
- ./docker/opencode/entrypoint.sh:/config/entrypoint.sh:ro
ports:
- "4096:4096"
healthcheck:
test:
- CMD-SHELL
- "wget -qO- --timeout=3 http://127.0.0.1:4096/global/health >/dev/null || curl -fsS http://127.0.0.1:4096/global/health >/dev/null || exit 1"
interval: 30s
timeout: 10s
retries: 3
start_period: 45s
deploy:
resources:
limits:
memory: 2G
cpus: "2"
networks:
- echodo
agent-server:
profiles: ["agent-server"]
image: ghcr.io/open-hands/agent-server:0.19.0
@ -238,6 +296,7 @@ services:
volumes:
echodo-workspace:
opencode-data:
networks:
echodo:

51
docker/opencode/README.md Normal file
View file

@ -0,0 +1,51 @@
# OpenCode on CT 116 (Submatter)
Headless coding harness for Submatter. Prefer this over OpenHands.
Runs `opencode serve` on `:4096` (HTTP API for the worker). Attach a TUI with
`opencode attach http://…:4096`, or use the browser via Traefik once Authentik
is happy. The image has no desktop browser — do not use `opencode web` in
compose without an `xdg-open` stub (entrypoint provides one if you switch back).
## Enable
On CT 116 `/opt/submatter/.env`:
```bash
COMPOSE_PROFILES=opencode
WORKER_SESSION_TYPE=opencode # after worker image rebuild with opencode session
OPENCODE_SERVER_PASSWORD=<openssl rand -hex 24>
OPENCODE_SERVER_URL=http://opencode:4096
OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
OMNIROUTE_API_KEY=<gateway inference key>
```
Then:
```bash
export COMPOSE_PROFILES=opencode
bash scripts/deploy-prod.sh up
# First time / after worker harness code lands:
# bash scripts/deploy-prod.sh build
```
Keep `WORKER_SESSION_TYPE=minimal` until the rebuilt worker image includes the
`opencode` session type (and the `yaml` dependency fix).
## URLs
| Surface | URL |
|---------|-----|
| Traefik | `https://opencode.stillwell.cloud` (Authentik + basic auth) |
| Worker API | `http://opencode:4096` on the `echodo` Docker network |
| LAN health | `curl -u opencode:$OPENCODE_SERVER_PASSWORD http://192.168.68.116:4096/global/health` |
## Auth
- Browser: Authentik via Traefik (`authentik-auth`) **and** OpenCode basic auth.
- Worker: same basic auth via `OPENCODE_SERVER_PASSWORD`.
- Models: `@omniroute/opencode-plugin` + `OMNIROUTE_API_KEY` (seeded into `auth.json` by `entrypoint.sh`).
## RAM
Container capped at 2 GB. Keep one concurrent agent until host RAM → 64 GB.

View file

@ -0,0 +1,27 @@
#!/bin/sh
# Seed OmniRoute auth for headless OpenCode, then start serve/web.
set -eu
CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME:-/data}/.config}"
DATA_HOME="${XDG_DATA_HOME:-${HOME:-/data}/.local/share}"
mkdir -p "$CONFIG_HOME/opencode" "$DATA_HOME/opencode" /data/bin
# Container has no desktop — stub xdg-open so `opencode web` does not exit.
if [ ! -x /data/bin/xdg-open ]; then
printf '#!/bin/sh\nexit 0\n' > /data/bin/xdg-open
chmod +x /data/bin/xdg-open
fi
export PATH="/data/bin:${PATH}"
if [ -f /config/opencode.json ]; then
cp /config/opencode.json "$CONFIG_HOME/opencode/opencode.json"
fi
if [ -n "${OMNIROUTE_API_KEY:-}" ]; then
# Keys must not contain double-quotes; OmniRoute keys are hex/base64-safe.
printf '{"omniroute":{"type":"api","key":"%s"}}\n' "$OMNIROUTE_API_KEY" \
> "$DATA_HOME/opencode/auth.json"
chmod 600 "$DATA_HOME/opencode/auth.json" || true
fi
exec "$@"

View file

@ -0,0 +1,16 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "http://192.168.68.108:20128"
}
]
],
"server": {
"port": 4096,
"hostname": "0.0.0.0"
}
}

View file

@ -1,13 +1,18 @@
#!/bin/sh
set -eu
# When OpenHands is enabled, seed the shared workspace volume from the deploy
# snapshot baked into the image (/app/repo). Agent-server reads the same path.
if [ "${WORKER_SESSION_TYPE:-minimal}" = "openhands" ]; then
# When a remote harness shares a workspace volume, seed it from the deploy
# snapshot baked into the image (/app/repo).
session_type="${WORKER_SESSION_TYPE:-minimal}"
if [ "$session_type" = "openhands" ] || [ "$session_type" = "opencode" ]; then
if [ "$session_type" = "opencode" ]; then
target="${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}"
else
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
fi
mkdir -p "$(dirname "$target")"
if [ ! -f "$target/pnpm-workspace.yaml" ]; then
echo "[worker-entrypoint] seeding OpenHands workspace at ${target}"
echo "[worker-entrypoint] seeding ${session_type} workspace at ${target}"
mkdir -p "$target"
cp -a /app/repo/. "$target/"
fi

View file

@ -117,7 +117,26 @@ If Forgejo is canonical git and you want push deploy without Actions:
---
## Phase B — OpenHands (optional)
## Phase B — OpenCode (preferred)
```bash
export COMPOSE_PROFILES=opencode
# Set in .env: WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_*
bash scripts/deploy-prod.sh build # first time / worker code change
# or: bash scripts/deploy-prod.sh up
```
| Variable | Notes |
|----------|-------|
| `OPENCODE_SERVER_PASSWORD` | `openssl rand -hex 24` — Traefik still fronts Authentik |
| `WORKER_SESSION_TYPE` | `opencode` |
| `OPENCODE_SERVER_URL` | `http://opencode:4096` (Docker DNS) |
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` or LAN `http://192.168.68.108:20128/v1` |
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
Web UI: `https://opencode.stillwell.cloud`. Details: `docker/opencode/README.md`.
### Legacy — OpenHands (optional)
```bash
export COMPOSE_PROFILES=agent-server
@ -129,11 +148,8 @@ bash scripts/deploy-prod.sh build
| `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` |
| `SESSION_API_KEY` | Same as above (worker) |
| `WORKER_SESSION_TYPE` | `openhands` |
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` |
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
Requires Docker socket on CT 116 (`nesting=1` on LXC).
Requires Docker socket on CT 116 (`nesting=1` on LXC). Prefer OpenCode unless debugging the old path.
---
## Troubleshooting

View file

@ -393,6 +393,9 @@ importers:
ioredis:
specifier: ^5.4.2
version: 5.10.1
yaml:
specifier: ^2.7.0
version: 2.8.3
zod:
specifier: ^3.24.0
version: 3.25.76

View file

@ -108,7 +108,16 @@ Handler: `deploy-webhook.sh`
ssh root@192.168.68.116 'cd /opt/submatter && git pull && bash scripts/deploy-prod.sh build'
```
## Phase B (OpenHands)
## Phase B (OpenCode — preferred)
```bash
export COMPOSE_PROFILES=opencode
bash scripts/deploy-prod.sh build
```
Set in `.env`: `WORKER_SESSION_TYPE=opencode`, `OPENCODE_SERVER_PASSWORD`, `OMNIROUTE_API_KEY` / `OMNIROUTE_BASE_URL`. See `docker/opencode/README.md`.
### Legacy Phase B (OpenHands)
```bash
export COMPOSE_PROFILES=agent-server
@ -116,7 +125,6 @@ bash scripts/deploy-prod.sh build
```
Set `WORKER_SESSION_TYPE=openhands`, `SESSION_API_KEY`, `LOCAL_BACKEND_API_KEY`, OmniRoute vars in `.env`.
## AGENT-DEPLOY.md handoff
After CT is live, add to your local `AGENT-DEPLOY.md` (gitignored):