Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Releases still mark tagged builds.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Tools

- **OpenCode 2 support.** `zro install opencode2` (aliases `oc2` and `opencode-v2`) launches the
OpenCode 2 client/server agent with isolated config, `--standalone`, Zro models, and MCP.
- OpenCode and OpenCode 2 config copies now skip symlinks and credential files
(`service.json`, `auth.json`, `mcp-auth.json`, `.env*`).

## [0.4.1] - 2026-10-28

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ unavailable in this mode.
| [Kilo Code](https://kilocode.ai/) | `zro install kilo` | Kilo's terminal coding agent |
| [Oh My Pi](https://ohmy.ai) | `zro install omp` | Power-user terminal agent |
| [OpenCode](https://opencode.ai) | `zro install opencode` | Open-source coding agent |
| [OpenCode 2](https://opencode.ai) | `zro install opencode2` | OpenCode 2.0 client/server agent |
| [Grok Build](https://x.ai) | `zro install grok` | xAI's coding agent |
| [Hermes](https://hermes-agent.nousresearch.com) | `zro install hermes` | Nous Research's agent |
| [OpenClaw](https://github.com/openclaw/openclaw) | `zro install openclaw` | Personal AI assistant |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"omp",
"openclaw",
"opencode",
"opencode2",
"pi",
"prime-agent",
"prime-intellect"
Expand Down
29 changes: 28 additions & 1 deletion scripts/ci/check-clients.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ if (!apiKey || apiKey === "ci-fake-key") {
}
const zroBin = process.env.ZRO_BIN || "zro";
const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-models-report.json");
const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"];
const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"];
const requestedHarness = process.argv[2] || "all";
if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) {
throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`);
Expand Down Expand Up @@ -68,6 +68,7 @@ try {
kilo: ["kilo", "--version"],
omp: ["omp", "--version"],
opencode: ["opencode", "--version"],
opencode2: ["opencode2", "--version"],
pi: ["pi", "--version"],
hermes: ["hermes", "--version"],
openclaw: ["openclaw", "--version"],
Expand Down Expand Up @@ -123,6 +124,32 @@ try {
passed("OpenCode lists all Zro models");
}

if (harnesses.includes("opencode2")) {
const openCode2Output = run(
zroBin,
["launch", "opencode2", "--model", selectedId, "--", "models"],
"OpenCode 2 model list"
);
const listed = openCode2Output
.trim()
.split(/\r?\n/)
.filter((line) => line.startsWith(`${PROVIDER_ID}/`))
.sort();
assert.deepEqual(listed, expectedModelIds);
passed("OpenCode 2 lists all Zro models");

const configPath = run(
zroBin,
["launch", "opencode2", "--model", selectedId, "--", "debug", "paths", "config"],
"OpenCode 2 config path"
).trim();
assert.ok(
configPath.includes(`${path.sep}zro${path.sep}sessions${path.sep}session-`),
`OpenCode 2 config escaped the zro session: ${configPath}`
);
passed("OpenCode 2 keeps its config isolated");
}

if (harnesses.includes("kilo")) {
const kiloOutput = run(
zroBin,
Expand Down
66 changes: 63 additions & 3 deletions scripts/ci/check-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ if (!apiKey || apiKey === "ci-fake-key") {
const zroBin = process.env.ZRO_BIN || "zro";
const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-live-report.json");
const runId = (process.env.ZRO_CI_RUN_ID || `${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-");
const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"];
const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"];
const requestedHarness = process.argv[2] || "all";
if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) {
throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`);
Expand Down Expand Up @@ -119,6 +119,7 @@ try {
else if (harness === "kilo") await checkKilo();
else if (harness === "omp") await checkOmp();
else if (harness === "opencode") await checkOpenCode();
else if (harness === "opencode2") await checkOpenCode2();
else if (harness === "pi") await checkPi();
else if (harness === "prime") await checkPrime();
else if (SKIPPED_LIVE_HARNESSES.has(harness)) skipped(harness);
Expand Down Expand Up @@ -155,6 +156,7 @@ async function collectVersions() {
kilo: ["kilo", "--version"],
omp: ["omp", "--version"],
opencode: ["opencode", "--version"],
opencode2: ["opencode2", "--version"],
pi: ["pi", "--version"],
hermes: ["hermes", "--version"],
openclaw: ["openclaw", "--version"],
Expand Down Expand Up @@ -318,6 +320,45 @@ async function checkOpenCode() {
passed("opencode.reasoning.max", { model: liveModel, reasoningTokens: max.tokens.reasoning });
}

async function checkOpenCode2() {
const marker = "ZRO_OPENCODE2_CACHE_OK";
const prompt = `Live cache probe ${runId}. Reply with exactly ${marker}.`;
const warmArgs = [
"launch", "opencode2", "--model", liveModel, "--", "run",
"--format", "json", "--auto",
"--model", `zro/${liveModel}#${liveLevels.offId}`, prompt
];
const readArgs = [
"launch", "opencode2", "--model", liveModel, "--", "run", "--continue",
"--format", "json", "--auto",
"--model", `zro/${liveModel}#${liveLevels.offId}`, prompt
];

const { first, second } = await probeCache({
warmArgs,
readArgs,
parse: (turn) => openCode2Turn(turn, marker),
readTokens: (turn) => turn.tokens.cache.read,
label: "OpenCode 2"
});
passed("opencode2.cache", {
model: liveModel,
effort: liveLevels.offId,
firstCacheRead: first.tokens.cache.read,
secondCacheRead: second.tokens.cache.read
});

const reasoningMarker = "ZRO_OPENCODE2_MAX_OK";
const max = await retryProbe("OpenCode 2 max reasoning", 3, async () => openCode2Turn(await runZro([
"launch", "opencode2", "--model", liveModel, "--", "run",
"--format", "json", "--auto",
"--model", `zro/${liveModel}#${liveLevels.maxId}`,
`Think briefly, then include ${reasoningMarker} in the answer.`
], "OpenCode 2 max reasoning", REASONING_TIMEOUT_MS), reasoningMarker));
assert.ok(max.tokens.reasoning > 0, "OpenCode 2 max effort reported no reasoning tokens");
passed("opencode2.reasoning.max", { model: liveModel, reasoningTokens: max.tokens.reasoning });
}

async function checkKilo() {
const marker = "ZRO_KILO_CACHE_OK";
const prompt = `Live cache probe ${runId}. Reply with exactly ${marker}.`;
Expand Down Expand Up @@ -519,6 +560,25 @@ function openCodeTurn(result, marker) {
return completed.part;
}

async function openCode2Turn(result, marker) {
const events = jsonLines(result.stdout);
const text = events
.filter((event) => event.type === "text")
.map((event) => event.part?.text || "")
.join("");
assert.match(text, new RegExp(marker));
const sessionID = events.find((event) => typeof event.sessionID === "string")?.sessionID;
assert.ok(sessionID, "OpenCode 2 emitted no session ID");
const output = await runZro([
"launch", "opencode2", "--model", liveModel, "--",
"api", "GET", `/api/session/${sessionID}/message`
], "OpenCode 2 session messages");
const messages = JSON.parse(output.stdout);
const assistant = messages.data?.findLast((message) => message.type === "assistant" && message.tokens);
assert.ok(assistant?.tokens, "OpenCode 2 session emitted no assistant token usage");
return assistant;
}

function kiloTurn(result, marker) {
const events = jsonLines(result.stdout);
const text = events
Expand Down Expand Up @@ -614,9 +674,9 @@ async function probeCache({ warmArgs, readArgs, parse, readTokens, label, attemp
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const suffix = attempt > 1 ? ` (attempt ${attempt}/${attempts})` : "";
first = parse(await runZro(warmArgs, `${label} cache warm-up${suffix}`));
first = await parse(await runZro(warmArgs, `${label} cache warm-up${suffix}`));
await afterWarm();
second = parse(await runZro(readArgs, `${label} cache read${suffix}`));
second = await parse(await runZro(readArgs, `${label} cache read${suffix}`));
if (readTokens(second) > 0) return { first, second };
console.log(` ${label}: cache miss (${attempt}/${attempts}), re-warming...`);
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const TOOLS: readonly ToolDescriptor[] = [
{ id: "kilo", name: "Kilo Code", hint: "Kilo's terminal coding agent", aliases: ["kc", "kilocode"], executable: "kilo", package: "@kilocode/cli@latest" },
{ id: "omp", name: "Oh My Pi", hint: "Power-user terminal coding agent", aliases: ["oh-my-pi", "ohmypi"], executable: "omp", installer: { url: "https://omp.sh/install", args: ["--binary"], binDir: ".local/bin" } },
{ id: "opencode", name: "OpenCode", hint: "Open source coding agent", aliases: ["oc"], executable: "opencode", package: "opencode-ai@latest" },
{ id: "opencode2", name: "OpenCode 2", hint: "OpenCode 2.0 client/server agent", aliases: ["oc2", "opencode-v2"], executable: "opencode2", installer: { url: "https://opencode.ai/v2/install", binDir: ".opencode/bin" } },
{ id: "grok", name: "Grok Build", hint: "xAI's coding agent", aliases: ["gr"], executable: "grok", installer: { url: "https://x.ai/cli/install.sh", binDir: ".local/bin" } },
{ id: "hermes", name: "Hermes", hint: "Nous Research's agent", aliases: ["h"], executable: "hermes", installer: { url: "https://hermes-agent.nousresearch.com/install.sh", args: ["--skip-setup"], binDir: ".local/bin" } },
{ id: "openclaw", name: "OpenClaw", hint: "Personal AI assistant", aliases: ["claw"], executable: "openclaw", package: "openclaw@latest" },
Expand Down
2 changes: 1 addition & 1 deletion src/engine/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ export function supportsAttachments(model: Pick<ZroModel, "modalities">): boolea
return model.modalities.input.some((modality) => modality !== "text");
}

export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "hermes", "openclaw", "pi", "prime"] as const;
export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "opencode2", "hermes", "openclaw", "pi", "prime"] as const;
120 changes: 120 additions & 0 deletions src/engine/tools/opencode-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import fs from "node:fs/promises";
import type { Dirent, Stats } from "node:fs";
import path from "node:path";
import { json5Serializer } from "../serializers.js";
import type { LaunchFile } from "../types.js";
import { asPlainObject } from "./helpers.js";

export const OPENCODE_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"] as const;
const GENERATED_DIRECTORIES = new Set([".git", "node_modules"]);
const PRIVATE_FILES = new Set(["service.json", "auth.json", "mcp-auth.json"]);

function isPrivateFile(name: string): boolean {
return PRIVATE_FILES.has(name) || name === ".env" || name.startsWith(".env.");
}

export async function readOpenCodeConfig(configRoot: string): Promise<Record<string, unknown>> {
let config: Record<string, unknown> = {};
for (const fileName of OPENCODE_CONFIG_FILES) {
const contents = await readOptionalFile(path.join(configRoot, fileName), "utf8");
if (contents === undefined) continue;
const parsed = json5Serializer.parse(contents) as Record<string, unknown>;
config = mergeConfig(config, parsed);
}
return config;
}

export function mergeConfig(
base: Record<string, unknown>,
override: Record<string, unknown>
): Record<string, unknown> {
const merged = { ...base };
for (const [key, value] of Object.entries(override)) {
const baseObject = asPlainObject(merged[key]);
const overrideObject = asPlainObject(value);
merged[key] = baseObject && overrideObject
? mergeConfig(baseObject, overrideObject)
: value;
}
return merged;
}

export async function collectUserConfigFiles(srcRoot: string, destRoot: string): Promise<LaunchFile[]> {
const files: LaunchFile[] = [];
const entries = await readOptionalDirectory(srcRoot);
if (!entries) return files;

const configFiles = new Set<string>(OPENCODE_CONFIG_FILES);
const ancestors = new Set([await fs.realpath(srcRoot)]);
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (configFiles.has(entry.name) || GENERATED_DIRECTORIES.has(entry.name) || isPrivateFile(entry.name)) continue;
Comment thread
zro-inference marked this conversation as resolved.
Resolved
await collectPath(
path.join(srcRoot, entry.name),
path.join(destRoot, entry.name),
files,
ancestors
);
}
return files;
}

async function collectPath(
srcPath: string,
destPath: string,
files: LaunchFile[],
ancestorDirectories: Set<string>
): Promise<void> {
const stats = await readOptionalStats(srcPath);
if (!stats || stats.isSymbolicLink()) return;
if (stats.isFile()) {
files.push({ path: destPath, contents: await fs.readFile(srcPath) });
return;
}
if (!stats.isDirectory()) return;

const realPath = await fs.realpath(srcPath);
if (ancestorDirectories.has(realPath)) return;
const nextAncestors = new Set(ancestorDirectories).add(realPath);
const entries = await fs.readdir(srcPath, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (GENERATED_DIRECTORIES.has(entry.name) || isPrivateFile(entry.name)) continue;
await collectPath(
path.join(srcPath, entry.name),
path.join(destPath, entry.name),
files,
nextAncestors
);
}
}

async function readOptionalDirectory(dirPath: string): Promise<Dirent[] | undefined> {
try {
return await fs.readdir(dirPath, { withFileTypes: true });
} catch (error) {
if (isMissingPathError(error)) return undefined;
throw error;
}
}

async function readOptionalFile(filePath: string, encoding: "utf8"): Promise<string | undefined> {
try {
return await fs.readFile(filePath, encoding);
} catch (error) {
if (isMissingPathError(error)) return undefined;
throw error;
}
}

async function readOptionalStats(filePath: string): Promise<Stats | undefined> {
try {
return await fs.lstat(filePath);
} catch (error) {
if (isMissingPathError(error)) return undefined;
throw error;
}
}

function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR");
}
Loading
Loading