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
| Hook | Stage | If jes blocks |
|---|---|---|
chat.message | input | Throws the refusal. |
tool.execute.before | tool_call | Throws the refusal. The tool does not run. |
tool.execute.after | tool_result | Sets 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}`;
}