Skip to main content

OpenCode

Run uvx jes login first. See Setup.

Install​

Save the plugin and its runner in the same plugin directory. Global plugins live in ~/.config/opencode/plugins/, project plugins in .opencode/plugins/.

mkdir -p ~/.config/opencode/plugins
uvx jes opencode-settings > ~/.config/opencode/plugins/opencode-plugin.ts
uvx jes runner-settings > ~/.config/opencode/plugins/jes-runner.ts

OpenCode loads every TypeScript file in that directory. The plugin exports JesGuard.

The runner calls uvx jes@<version> hook, pinned to the release that printed it, so print both files again after you upgrade jes. If jes takes over 60 seconds, crashes, or prints no decision, the runner blocks with Blocked: jes did not answer.

What each hook does​

HookStageIf jes blocks
chat.messageinputThrows the refusal.
tool.execute.beforetool_callThrows the refusal. The tool does not run.
tool.execute.aftertool_resultSets output.output to the refusal.

Limits​

OpenCode has no hook that rewrites the reply on display, so jes doesn't check the reply here.

Source​

opencode-plugin.ts
// Checks each step with jes through ./jes-runner.ts, which `jes runner-settings` prints.
// OpenCode has no display-only reply hook, so this plugin does not rewrite
// the assistant reply. It checks the user prompt, the tool call, and the
// tool result. A blocked tool result replaces output.output with onward.
import { sessionOf, spawnJes, type JesRunner } from "./jes-runner.ts";

type MessageInput = { sessionID?: string };
type MessageOutput = { message?: { content?: unknown } };
type ToolInput = { tool: string; sessionID?: string };
type ToolArgs = { args?: Record<string, unknown> };
type ToolOutput = { output?: unknown };

export function createJesGuard(run: JesRunner = spawnJes) {
return async () => {
return {
"chat.message": async (input: MessageInput, output: MessageOutput) => {
const content = output.message?.content;
const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
const checked = run({
stage: "input",
text,
session_id: sessionOf(input.sessionID, "opencode"),
});
if (!checked.ok) {
throw new Error(checked.onward);
}
},
"tool.execute.before": async (input: ToolInput, output: ToolArgs) => {
const checked = run({
stage: "tool_call",
tool: input.tool,
arguments: output.args ?? {},
session_id: sessionOf(input.sessionID, "opencode"),
});
if (!checked.ok) {
throw new Error(checked.onward);
}
},
"tool.execute.after": async (input: ToolInput, output: ToolOutput) => {
const text =
typeof output.output === "string" ? output.output : JSON.stringify(output.output ?? "");
const checked = run({
stage: "tool_result",
tool: input.tool,
text,
session_id: sessionOf(input.sessionID, "opencode"),
});
if (!checked.ok) {
output.output = checked.onward;
}
},
};
};
}

export const JesGuard = createJesGuard();
jes-runner.ts
// Calls `uvx jes@0.0.1 hook` on stdin. uvx ships with uv, so there is no separate jes install.
// `jes runner-settings` prints this file with the version filled in. Print it again after upgrading jes.
import { spawnSync } from "node:child_process";

export type JesDecision = { ok: boolean; onward: string; decision: string };

export type JesRunner = (body: Record<string, unknown>) => JesDecision;

// The agent waits for every check, so a check that hangs blocks after this long.
const TIMEOUT_MS = 60_000;
const UNANSWERED: JesDecision = {
ok: false,
onward: "Blocked: jes did not answer.",
decision: "block",
};

export function spawnJes(body: Record<string, unknown>): JesDecision {
const run = spawnSync("uvx", ["jes@0.0.1", "hook"], {
input: JSON.stringify(body),
encoding: "utf8",
timeout: TIMEOUT_MS,
maxBuffer: 16 * 1024 * 1024,
});
return parseDecision(run.stdout);
}

// A jes that crashed, timed out, or is misconfigured prints nothing usable. That blocks.
export function parseDecision(stdout: string | null | undefined): JesDecision {
const text = (stdout ?? "").trim();
if (!text) {
return UNANSWERED;
}
try {
const parsed = JSON.parse(text) as Partial<JesDecision>;
if (typeof parsed.ok !== "boolean" || typeof parsed.onward !== "string") {
return UNANSWERED;
}
return { ok: parsed.ok, onward: parsed.onward, decision: parsed.ok ? "allow" : "block" };
} catch {
return UNANSWERED;
}
}

export function sessionOf(value: string | undefined, fallback: string): string {
const cleaned = (value ?? fallback).replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 200);
return /^[A-Za-z0-9]/.test(cleaned) ? cleaned : `s${cleaned}`;
}