switch to v2
Release / release (push) Successful in 8s

This commit is contained in:
2026-09-09 11:18:03 +02:00
parent 0378edf5f8
commit bb95f9334b
11 changed files with 1450 additions and 577 deletions
+228 -173
View File
@@ -1,192 +1,247 @@
import type { PluginModule } from "@opencode-ai/plugin";
import type {
AgentConfig,
Config,
PermissionConfig,
PermissionObjectConfig,
} from "@opencode-ai/sdk/v2";
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
import type { ModelInfo } from "@opencode/client";
import { z } from "zod";
import { gptAgents, settings, workerNames } from "./config.js";
import { discover } from "./provider.js";
import { descriptions, routingGuide } from "./routing.js";
// OpenCode permission patterns are wildcards, not filesystem globs.
// Permission actions use wildcards, not filesystem globs.
function matches(value: string, pattern: string) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, process.platform === "win32" ? "si" : "s").test(value);
return new RegExp(`^${escaped}$`, "s").test(value);
}
function rulesFor(action: string, defaults: PermissionObjectConfig, ...layers: PermissionConfig[]) {
const result = { ...defaults };
for (const layer of layers) {
for (const [key, rule] of Object.entries(
layer instanceof Object ? layer : { "*": layer },
)) {
if (!rule || !matches(action, key)) continue;
for (const [pattern, effect] of Object.entries(
rule instanceof Object ? rule : { "*": rule },
)) {
// Last matching rule wins. A new wildcard supersedes every earlier rule.
if (pattern === "*") {
for (const previous of Object.keys(result)) delete result[previous];
}
delete result[pattern];
result[pattern] = effect;
}
}
}
return result;
}
const reading = [
"read",
"glob",
"grep",
"list",
"todoread",
"todowrite",
"webfetch",
"websearch",
"question",
"external_directory",
"doom_loop",
];
const delegation = z.object({ agent: z.string() });
const todos = z.array(
z.object({
id: z.string(),
content: z.string(),
status: z.enum(["pending", "in_progress", "completed", "cancelled"]),
priority: z.enum(["high", "medium", "low"]),
}),
);
function flatPermission(action: string, ...layers: PermissionConfig[]) {
return (
Object.entries(rulesFor(action, { "*": "allow" }, ...layers)).findLast(([pattern]) =>
matches("*", pattern),
)?.[1] ?? "allow"
);
}
export default {
export default Plugin.define({
id: "stanp-opencode",
async server(context, options) {
const selected = settings.parse({ mode: "gpt", ...options });
const activeWorkers = new Set<string>();
return {
async config(input) {
// SAFETY: OpenCode 1.18.30 passes its validated live Config here. The plugin's
// legacy SDK type is stale; SDK/v2 describes the actual permission schema.
const config = input as Config;
const provider = await discover(config, selected.provider);
if (!provider) return;
const assignments = selected.mode === "gpt" ? gptAgents : selected.agents;
const deep = selected.mode === "custom" || provider.models.has("gpt-6-astra");
const workers = workerNames.filter((name) => deep || !name.startsWith("deep-"));
const coordinate =
selected.mode === "gpt" && !deep
? { model: "gpt-5.6-sol", effort: "medium" }
: assignments.coordinate;
if (
![coordinate, ...workers.map((name) => assignments[name])].every(({ model }) =>
provider.models.has(model),
)
)
return;
async setup(context) {
const selected = settings.parse({ mode: "gpt", ...context.options });
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
let inherited: Agent.Info["permissions"] = Agent.Info.default(
Agent.ID.make("build"),
).permissions;
let permissionKey = JSON.stringify(inherited);
const controller = new AbortController();
// Subscribe before the first read: configuration can publish its catalog while
// asynchronous setup is running.
const updates = (async () => {
for await (const event of context.event.subscribe({ signal: controller.signal })) {
if (event.type === "catalog.updated") {
models = await discover(context, selected.provider);
await context.agent.reload();
} else if (event.type === "agent.updated") {
// The configured build transform may run after ours. Read the final
// registry, then replay only when its effective permissions change.
const { data } = await context.agent.list();
const permissions =
data.find((agent) => agent.id === "build")?.permissions ??
Agent.Info.default(Agent.ID.make("build")).permissions;
const key = JSON.stringify(permissions);
if (key === permissionKey) continue;
inherited = permissions;
permissionKey = key;
await context.agent.reload();
}
}
})().catch((error) => {
if (!controller.signal.aborted)
console.error("stanp-opencode catalog update failed", error);
});
models = await discover(context, selected.provider);
const { data: initialAgents } = await context.agent.list();
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
permissionKey = JSON.stringify(inherited);
const assignments = selected.mode === "gpt" ? gptAgents : selected.agents;
let workers: (typeof workerNames)[number][] = [];
const active = new Set<string>();
const agents = (config.agent ??= {});
const global = config.permission ?? {};
const build = agents.build?.permission ?? {};
// Custom agents already inherit OpenCode's defaults and global permissions.
// Supply only build's differing defaults, explicit todo access (task otherwise
// disables it), and build's original overrides. Do not flatten global rules.
const inherited: Exclude<PermissionConfig, string> = {
question: flatPermission("question", global),
plan_enter: flatPermission("plan_enter", global),
todowrite: flatPermission("todowrite", global),
};
for (const [key, rule] of Object.entries(
build instanceof Object ? build : { "*": build },
)) {
delete inherited[key];
inherited[key] = rule;
}
delete inherited.task;
inherited.task = "deny";
for (const name of workers) {
const assignment = assignments[name];
const options: AgentConfig["options"] = {
reasoningEffort: assignment.effort,
await context.agent.transform((editor) => {
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
const coordinate =
selected.mode === "gpt" && !deep
? { model: "gpt-5.6-sol", effort: "medium" }
: assignments.coordinate;
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
({ model }) => models.has(model),
)
? candidates
: [];
active.clear();
for (const name of workers) active.add(name);
if (workers.length === 0) return;
for (const name of workers) {
const assignment = assignments[name];
const available = models.get(assignment.model)!;
editor.update(name, (agent) => {
agent.name = Agent.Name.make(name);
agent.mode = "subagent";
agent.hidden = false;
agent.description = descriptions[name];
agent.model = {
providerID: Provider.ID.make(available.model.providerID),
id: Model.ID.make(available.model.id),
};
const tier = provider.models.get(assignment.model)?.serviceTier;
if (selected.lunaPriority && tier) {
options.serviceTier = tier;
}
agents[name] = {
mode: "subagent",
description: descriptions[name],
model: `${selected.provider}/${assignment.model}`,
options,
permission: structuredClone(inherited),
};
activeWorkers.add(name);
}
const reading: Exclude<PermissionConfig, string> = {};
for (const name of ["glob", "grep", "list", "todoread"]) {
reading[name] = rulesFor(name, { "*": "allow" }, global, build);
}
agents.coordinate = {
mode: "primary",
description:
"Read, plan, and coordinate specialist workers; never implement directly.",
model: `${selected.provider}/${coordinate.model}`,
options: { reasoningEffort: coordinate.effort },
prompt: routingGuide(deep),
permission: {
"*": "deny",
...reading,
read: rulesFor(
"read",
{
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
global,
build,
),
external_directory: rulesFor(
"external_directory",
{ "*": "ask" },
global,
build,
),
doom_loop: "ask",
webfetch: flatPermission("webfetch", global, build),
websearch: flatPermission("websearch", global, build),
todowrite: flatPermission("todowrite", global, build),
question: flatPermission("question", global, build),
task: {
"*": "deny",
...Object.fromEntries(workers.map((name) => [name, "allow" as const])),
agent.request = {
settings: {
reasoningEffort: assignment.effort,
},
},
};
},
async "tool.execute.before"(input, output) {
if (activeWorkers.size === 0 || input.tool !== "task") return;
const target = output.args?.subagent_type;
// Primary mode only hides coordinate from suggestions; @mentions and subtask
// commands can bypass task permissions, so enforce the policy here as well.
if (target === "coordinate") {
throw new Error("coordinate is a primary agent and cannot be delegated to");
}
const recent = await context.client.session.messages({
path: { id: input.sessionID },
query: { limit: 1 },
throwOnError: true,
headers: {},
body: {},
};
if (selected.lunaPriority && available.serviceTier)
agent.request.settings.serviceTier = available.serviceTier;
agent.permissions = [
...structuredClone(inherited),
{ action: "subagent", resource: "*", effect: "deny" },
];
});
const message = recent.data?.at(-1)?.info;
const parent =
message?.role === "assistant"
? (
await context.client.session.message({
path: { id: input.sessionID, messageID: message.parentID },
throwOnError: true,
})
).data?.info
: message;
// Subtask commands label their synthetic assistant with the TARGET agent;
// its parent user message identifies the caller, including resumed sessions.
if (parent?.role !== "user")
throw new Error("Cannot determine task caller; refusing delegation");
if (activeWorkers.has(parent.agent))
throw new Error("Workers cannot delegate tasks");
if (parent.agent === "coordinate" && !activeWorkers.has(target)) {
throw new Error("coordinate may delegate only to its available workers");
}
},
}
editor.update("coordinate", (agent) => {
const available = models.get(coordinate.model)!;
agent.name = Agent.Name.make("coordinate");
agent.mode = "primary";
agent.hidden = false;
agent.description =
"Read, plan, and coordinate specialist workers; never implement directly.";
agent.model = {
providerID: Provider.ID.make(available.model.providerID),
id: Model.ID.make(available.model.id),
};
agent.request = {
settings: { reasoningEffort: coordinate.effort },
headers: {},
body: {},
};
agent.system = routingGuide(deep);
agent.permissions = [
{ action: "*", resource: "*", effect: "deny" },
...reading.flatMap((action) =>
inherited
.filter((rule) => matches(action, rule.action))
.map((rule) => ({ ...rule, action })),
),
...workers.map((resource) => ({
action: "subagent",
resource,
effect: "allow" as const,
})),
];
});
});
async function assertTodo(agentID: Agent.ID, action: string) {
const { data: agent } = await context.agent.get({ agentID });
const effect =
agent.permissions.findLast(
(rule) => matches(action, rule.action) && matches("*", rule.resource),
)?.effect ?? "ask";
if (effect !== "allow")
throw new Error(
`${action} requires an allow permission: OpenCode beta's public plugin API cannot request custom-tool permission approval (${effect}).`,
);
}
await context.tool.transform((editor) => {
if (!editor.get("todowrite"))
editor.add({
name: "todowrite",
description:
"Replace this session's todo list to track the work and its progress.",
input: z.object({ todos }),
options: { codemode: false },
async execute(input, tool) {
await assertTodo(tool.agent, "todowrite");
await context.storage.set(`todos:${tool.sessionID}`, input.todos);
return { content: JSON.stringify(input.todos) };
},
});
if (!editor.get("todoread"))
editor.add({
name: "todoread",
description: "Read this session's todo list.",
input: z.object({}),
options: { codemode: false },
async execute(_input, tool) {
await assertTodo(tool.agent, "todoread");
const stored = await context.storage.get(`todos:${tool.sessionID}`);
return {
content: JSON.stringify(
stored === undefined ? [] : todos.parse(stored),
),
};
},
});
});
await context.session.hook("context", (event) => {
if (workerNames.some((name) => name === event.agent)) {
delete event.tools.subagent;
return;
}
const tool = event.tools.subagent;
if (event.agent === "coordinate" && workers.length === 0) {
delete event.tools.subagent;
return;
}
if (event.agent !== "coordinate" || !tool) return;
tool.description = `Delegate a self-contained assignment to one of these workers:\n${workers.map((name) => `- ${name}: ${descriptions[name]}`).join("\n")}`;
// The executable definition remains native; constrain only this request's schema.
const properties = z.record(z.string(), z.unknown()).parse(tool.input.properties ?? {});
tool.input = {
...tool.input,
properties: { ...properties, agent: { type: "string", enum: workers } },
};
});
await context.tool.hook("execute.before", (event) => {
if (event.tool !== "subagent") return;
const { agent } = delegation.parse(event.input);
if (agent === "coordinate") throw new Error("coordinate cannot run as a subagent");
if (workerNames.some((name) => name === event.agent))
throw new Error("Workers cannot delegate tasks");
if (event.agent === "coordinate" && !active.has(agent))
throw new Error("coordinate may delegate only to its available workers");
});
// Commands can create child sessions without going through the subagent tool.
await context.session.hook("prompt", async (event) => {
const child = await context.session.get({ sessionID: event.sessionID });
if (!child.parentID) return;
if (child.agent === "coordinate")
throw new Error("coordinate cannot run as a subagent");
const parent = await context.session.get({ sessionID: child.parentID });
if (workerNames.some((name) => name === parent.agent))
throw new Error("Workers cannot delegate tasks");
if (
parent.agent === "coordinate" &&
(child.agent === undefined || !active.has(child.agent))
)
throw new Error("coordinate may delegate only to its available workers");
});
return async () => {
controller.abort();
await updates;
};
},
} satisfies PluginModule;
});
+25 -193
View File
@@ -1,200 +1,32 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import type { Config } from "@opencode-ai/sdk/v2";
import type { Plugin } from "@opencode/plugin";
import type { ModelInfo } from "@opencode/client";
import { z } from "zod";
const endpoint = z.object({ npm: z.string().optional(), api: z.string().optional() });
const catalogProvider = endpoint.extend({
id: z.string(),
env: z.array(z.string()),
models: z.record(
z.string(),
z.object({
id: z.string(),
status: z.enum(["active", "alpha", "beta", "deprecated"]).optional(),
provider: endpoint.optional(),
}),
),
});
const record = z.record(z.string(), z.unknown());
const auth = z.discriminatedUnion("type", [
z.object({
type: z.literal("api"),
key: z.string(),
metadata: z.record(z.string(), z.string()).optional(),
}),
z.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number().int().nonnegative(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
}),
z.object({ type: z.literal("wellknown"), key: z.string(), token: z.string() }),
]);
const priorityOptions = z.object({ serviceTier: z.enum(["priority", "fast"]) });
const missingFile = z.object({ code: z.literal("ENOENT") });
type Model = {
id: string;
npm: string;
baseURL: string | undefined;
serviceTier: z.infer<typeof priorityOptions>["serviceTier"] | undefined;
status: string | undefined;
};
async function local(path: string) {
try {
return await readFile(path, "utf8");
} catch (error) {
if (missingFile.safeParse(error).success) return undefined;
throw error;
function priority(model: ModelInfo) {
if (model.modelID !== "gpt-5.6-luna") return undefined;
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
return settings.serviceTier;
}
}
async function catalog() {
const source = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai";
const filename =
source === "https://models.opencode.ai"
? "models.json"
: `models-${createHash("sha1").update(source).digest("hex")}.json`;
const cache = process.env.XDG_CACHE_HOME;
const path =
process.env.OPENCODE_MODELS_PATH ??
join(cache && isAbsolute(cache) ? cache : join(homedir(), ".cache"), "opencode", filename);
const text = await local(path);
if (text !== undefined) return record.parse(JSON.parse(text));
const disabled = process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase();
if (disabled === "true" || disabled === "1") return {};
const response = await fetch(`${source}/api.json`, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Model catalog request failed (${response.status})`);
return record.parse(await response.json());
}
async function authenticated(providerID: string) {
const data = process.env.XDG_DATA_HOME;
const text =
process.env.OPENCODE_AUTH_CONTENT ||
(await local(
join(
data && isAbsolute(data) ? data : join(homedir(), ".local/share"),
"opencode",
"auth.json",
),
));
if (text === undefined) return false;
// Neither JSON nor schema diagnostics may include credential contents.
try {
const entries = record.parse(JSON.parse(text));
if (!Object.hasOwn(entries, providerID)) return false;
const credential = auth.parse(entries[providerID]);
return credential.type === "api" || credential.type === "oauth";
} catch {
throw new Error("Invalid OpenCode authentication data");
}
}
// OpenAI lists Luna Fast/Priority support; account limits can still downgrade requests.
// https://developers.openai.com/api/docs/guides/fast-mode
function official(npm: string | undefined, baseURL: string | undefined) {
if (npm !== "@ai-sdk/openai") return false;
if (!baseURL) return true;
if (!URL.canParse(baseURL)) return false;
const url = new URL(baseURL);
return url.origin === "https://api.openai.com" && /^\/v1\/?$/.test(url.pathname);
}
/**
* Bootstrap snapshot only: provider.list would wait for this config hook itself.
* ponytail: catalog/config/env/stored auth only; bundled snapshots, experimental
* catalog modes, runtime discovery, and later plugin/auth-loader rewrites are not
* observable here. A post-bootstrap provider hook is needed to include those.
* Credentials establish connectivity, not model entitlement or OAuth validity.
*/
export async function discover(config: Config, providerID: string) {
if (config.disabled_providers?.includes(providerID)) return undefined;
if (config.enabled_providers && !config.enabled_providers.includes(providerID))
const baseURL = model.settings?.baseURL;
if (model.package !== "@opencode/ai/providers/openai" && model.package !== "@ai-sdk/openai")
return undefined;
const configured =
config.provider && Object.hasOwn(config.provider, providerID)
? config.provider[providerID]
: undefined;
const entries = await catalog();
const provider = Object.hasOwn(entries, providerID)
? catalogProvider.parse(entries[providerID])
if (baseURL === undefined) return "priority";
const endpoint = z.url().safeParse(baseURL);
if (!endpoint.success) return undefined;
const url = new URL(endpoint.data);
return url.origin === "https://api.openai.com" && /^\/v1\/?$/.test(url.pathname)
? "priority"
: undefined;
if (!provider && !configured) return undefined;
if (
!configured &&
!provider?.env.some((key) => Boolean(process.env[key])) &&
!(await authenticated(providerID))
) {
return undefined;
}
}
const effective = new Map<string, Model>();
for (const [key, model] of Object.entries(provider?.models ?? {})) {
const npm = model.provider?.npm ?? provider?.npm ?? "@ai-sdk/openai-compatible";
const baseURL = configured?.options?.baseURL || (model.provider?.api ?? provider?.api);
effective.set(key, {
id: model.id,
npm,
baseURL,
status: model.status,
serviceTier: priorityOptions.safeParse(configured?.options).data?.serviceTier,
});
}
for (const [key, model] of Object.entries(configured?.models ?? {})) {
const existing = effective.get(model.id ?? key);
const npm =
model.provider?.npm ??
configured?.npm ??
existing?.npm ??
provider?.npm ??
"@ai-sdk/openai-compatible";
const baseURL =
configured?.options?.baseURL ||
(model.provider?.api ?? configured?.api ?? existing?.baseURL ?? provider?.api);
let serviceTier = priorityOptions.safeParse({
serviceTier: model.options?.serviceTier ?? configured?.options?.serviceTier,
}).data?.serviceTier;
if (!serviceTier) {
for (const variant of Object.values(model.variants ?? {})) {
if (variant.disabled) continue;
serviceTier = priorityOptions.safeParse(variant).data?.serviceTier;
if (serviceTier) break;
}
}
effective.set(key, {
id: model.id ?? existing?.id ?? key,
npm,
baseURL,
status: model.status ?? existing?.status,
serviceTier,
});
}
const experimental = process.env.OPENCODE_ENABLE_EXPERIMENTAL_MODELS?.toLowerCase();
for (const [key, model] of effective) {
if (
model.status === "deprecated" ||
(model.status === "alpha" && experimental !== "true" && experimental !== "1") ||
configured?.blacklist?.includes(key) ||
(configured?.whitelist && !configured.whitelist.includes(key)) ||
(key === "gpt-5-chat-latest" &&
["openai", "github-copilot", "openrouter"].includes(providerID)) ||
(providerID === "openrouter" && key === "openai/gpt-5-chat")
) {
effective.delete(key);
continue;
}
model.serviceTier =
model.id === "gpt-5.6-luna"
? (model.serviceTier ??
(official(model.npm, model.baseURL) ? "priority" : undefined))
: undefined;
}
return { models: effective };
export async function discover(context: Plugin.Context, providerID: string) {
// Native lists already apply activation, integrations, configuration, and catalog transforms.
const { data } = await context.catalog.model.list();
const models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>();
for (const model of data) {
if (model.providerID === providerID && model.enabled)
models.set(model.id, { model, serviceTier: priority(model) });
}
return models;
}