+228
-173
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user