This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { Rpc } from "@opencode/plugin/rpc";
|
||||
import { z } from "zod";
|
||||
|
||||
// A native host boundary: Multi-work must load after Agents and use its final build permissions.
|
||||
export const AgentsRpc = Rpc.define({
|
||||
id: "stanp-opencode.agents",
|
||||
methods: {
|
||||
permissions: {
|
||||
input: z.object({}),
|
||||
output: z.array(
|
||||
z.object({
|
||||
action: z.string(),
|
||||
resource: z.string(),
|
||||
effect: z.enum(["allow", "ask", "deny"]),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
});
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
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, modelKey } from "./provider.js";
|
||||
import { descriptions, routingGuide } from "./routing.js";
|
||||
import { Skill } from "@opencode/schema/skill";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import antiOverengineering from "./anti-overengineering.md" with { type: "text" };
|
||||
import { AgentsRpc } from "./agents-rpc.js";
|
||||
|
||||
// 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}$`, "s").test(value);
|
||||
}
|
||||
|
||||
const reading = [
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"list",
|
||||
"todoread",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"question",
|
||||
"skill",
|
||||
"external_directory",
|
||||
"doom_loop",
|
||||
];
|
||||
const delegation = z.object({ agent: z.string() });
|
||||
export default Plugin.define({
|
||||
id: "stanp-opencode.agents",
|
||||
async setup(context) {
|
||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
||||
const lunaPriority =
|
||||
selected.mode === "gpt" &&
|
||||
selected.provider === "openai" &&
|
||||
selected.lunaPriority === true;
|
||||
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);
|
||||
let codegraphTools: string[] = [];
|
||||
let agentTransformReady = false;
|
||||
const controller = new AbortController();
|
||||
// Subscribe before the first read: configuration can publish its models while
|
||||
// asynchronous setup is running.
|
||||
const updates = (async () => {
|
||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
||||
if (event.location) {
|
||||
const workspaceID =
|
||||
"workspaceID" in event.location ? event.location.workspaceID : undefined;
|
||||
if (
|
||||
event.location.directory !== context.location.directory ||
|
||||
workspaceID !== context.location.workspaceID
|
||||
)
|
||||
continue;
|
||||
}
|
||||
if (event.type === "model.updated") {
|
||||
models = await discover(context, lunaPriority);
|
||||
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 model update failed", error);
|
||||
});
|
||||
models = await discover(context, lunaPriority);
|
||||
const { data: initialAgents } = await context.agent.list();
|
||||
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||
permissionKey = JSON.stringify(inherited);
|
||||
const registration = await context.rpc.register(AgentsRpc, {
|
||||
permissions: async () => inherited.map((rule) => ({ ...rule })),
|
||||
});
|
||||
const assignments =
|
||||
selected.mode === "gpt"
|
||||
? Object.fromEntries(
|
||||
workerNames.map((name) => [
|
||||
name,
|
||||
{
|
||||
...gptAgents[name],
|
||||
provider: selected.provider,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: selected.agents;
|
||||
let workers: (typeof workerNames)[number][] = [];
|
||||
const active = new Set<string>();
|
||||
|
||||
await context.skill.transform((editor) => {
|
||||
if (editor.get("anti-overengineering")) return;
|
||||
editor.add({
|
||||
id: Skill.ID.make("anti-overengineering"),
|
||||
name: Skill.Name.make("anti-overengineering"),
|
||||
description: "Build the smallest correct solution without sacrificing correctness.",
|
||||
path: AbsolutePath.make(fileURLToPath(import.meta.url)),
|
||||
content: antiOverengineering,
|
||||
});
|
||||
});
|
||||
|
||||
await context.tool.transform((editor) => {
|
||||
const next = editor
|
||||
.list()
|
||||
.map((tool) => tool.id)
|
||||
.filter((id) => id.startsWith("codegraph_"))
|
||||
.sort();
|
||||
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||
codegraphTools = next;
|
||||
if (agentTransformReady)
|
||||
queueMicrotask(() => {
|
||||
void context.agent
|
||||
.reload()
|
||||
.catch((error) =>
|
||||
console.error("stanp-opencode agent reload failed", error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await context.agent.transform((editor) => {
|
||||
const coordinates = (assignment: { model: string }) => {
|
||||
if (selected.mode === "gpt")
|
||||
return { provider: selected.provider, model: assignment.model };
|
||||
const slash = assignment.model.indexOf("/");
|
||||
return {
|
||||
provider: assignment.model.slice(0, slash),
|
||||
model: assignment.model.slice(slash + 1),
|
||||
};
|
||||
};
|
||||
const availableFor = (assignment: { model: string }) => {
|
||||
const { provider, model } = coordinates(assignment);
|
||||
return models.get(modelKey(provider, model));
|
||||
};
|
||||
const deepLo = assignments["deep-lo"];
|
||||
const deep =
|
||||
deepLo !== undefined &&
|
||||
assignments["deep-hi"] !== undefined &&
|
||||
availableFor(deepLo) !== undefined;
|
||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||
const coordinate = deep ? deepLo : assignments["good-lo"];
|
||||
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
|
||||
(assignment) => assignment !== undefined && availableFor(assignment) !== undefined,
|
||||
)
|
||||
? 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 = availableFor(assignment)!;
|
||||
editor.update(name, (agent) => {
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === assignment.effort,
|
||||
);
|
||||
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),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: assignment.effort },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.permissions = [
|
||||
...structuredClone(inherited),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
];
|
||||
});
|
||||
}
|
||||
editor.update("coordinate", (agent) => {
|
||||
const available = availableFor(coordinate)!;
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === coordinate.effort,
|
||||
);
|
||||
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),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: coordinate.effort },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.system = routingGuide(deep);
|
||||
agent.permissions = [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
...reading.flatMap((action) =>
|
||||
inherited
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
),
|
||||
...codegraphTools.flatMap((action) =>
|
||||
inherited
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
),
|
||||
...workers.map((resource) => ({
|
||||
action: "subagent",
|
||||
resource,
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
];
|
||||
});
|
||||
});
|
||||
agentTransformReady = true;
|
||||
async function assertMultiWork() {
|
||||
const { data } = await context.plugin.list();
|
||||
if (
|
||||
!data.some(
|
||||
(plugin) =>
|
||||
plugin.id === "stanp-opencode.multi-work" &&
|
||||
plugin.state.status === "active",
|
||||
)
|
||||
)
|
||||
throw new Error("coordinate cannot run as a subagent without Multi-work");
|
||||
}
|
||||
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", async (event) => {
|
||||
if (event.tool !== "subagent") return;
|
||||
const { agent } = delegation.parse(event.input);
|
||||
if (agent === "coordinate" && event.agent !== "multi-work")
|
||||
throw new Error("coordinate cannot run as a subagent without Multi-work");
|
||||
if (agent === "coordinate") await assertMultiWork();
|
||||
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;
|
||||
const parent = await context.session.get({ sessionID: child.parentID });
|
||||
if (child.agent === "coordinate" && parent.agent !== "multi-work")
|
||||
throw new Error("coordinate cannot run as a subagent without Multi-work");
|
||||
if (child.agent === "coordinate") await assertMultiWork();
|
||||
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;
|
||||
await registration.dispose();
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -24,14 +24,9 @@ const customAssignment = gptAssignment.extend({
|
||||
}, "model must use the provider/model convention"),
|
||||
});
|
||||
|
||||
const common = {
|
||||
playwright: z.boolean().default(false),
|
||||
};
|
||||
|
||||
export const settings = z.discriminatedUnion("mode", [
|
||||
z
|
||||
.strictObject({
|
||||
...common,
|
||||
mode: z.literal("gpt"),
|
||||
provider: z.string().trim().min(1).default("openai"),
|
||||
lunaPriority: z.boolean().optional(),
|
||||
@@ -50,7 +45,6 @@ export const settings = z.discriminatedUnion("mode", [
|
||||
: value;
|
||||
}),
|
||||
z.strictObject({
|
||||
...common,
|
||||
mode: z.literal("custom"),
|
||||
agents: z.strictObject({
|
||||
"fast-lo": customAssignment,
|
||||
|
||||
-449
@@ -1,449 +0,0 @@
|
||||
import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
|
||||
import type { RpcRegistration } from "@opencode/plugin/promise/rpc";
|
||||
import type { ModelInfo } from "@opencode/client";
|
||||
import { z } from "zod";
|
||||
import { gptAgents, settings, workerNames } from "./config.js";
|
||||
import { discover, modelKey } from "./provider.js";
|
||||
import { descriptions, routingGuide } from "./routing.js";
|
||||
import { QuotaTracker } from "./quota.js";
|
||||
import { QuotaRpc } from "./quota-rpc.js";
|
||||
import { playwrightMcpCommand } from "./playwright.js";
|
||||
import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
||||
import { Effect } from "effect";
|
||||
import { bindingKey, bindingSchema, installMultiWork } from "./multi-work.js";
|
||||
import { Skill } from "@opencode/schema/skill";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import antiOverengineering from "./anti-overengineering.md" with { type: "text" };
|
||||
|
||||
// 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}$`, "s").test(value);
|
||||
}
|
||||
|
||||
const reading = [
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"list",
|
||||
"todoread",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"question",
|
||||
"skill",
|
||||
"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"]),
|
||||
}),
|
||||
);
|
||||
|
||||
const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permissions"]) => void) =>
|
||||
Plugin.define({
|
||||
id: "stanp-opencode",
|
||||
async setup(context) {
|
||||
const selected = settings.parse({ mode: "gpt", ...context.options });
|
||||
const lunaPriority =
|
||||
selected.mode === "gpt" &&
|
||||
selected.provider === "openai" &&
|
||||
selected.lunaPriority === true;
|
||||
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);
|
||||
let hasCodegraph = false;
|
||||
let codegraphTools: string[] = [];
|
||||
let agentTransformReady = false;
|
||||
const controller = new AbortController();
|
||||
let quotaRegistration: RpcRegistration<typeof QuotaRpc> | undefined;
|
||||
const quota = new QuotaTracker(context, async () => {
|
||||
await quotaRegistration?.events.emit("updated", {});
|
||||
});
|
||||
quotaRegistration = await context.rpc.register(QuotaRpc, {
|
||||
status: async () => quota.status() ?? null,
|
||||
});
|
||||
await quota.init();
|
||||
// Subscribe before the first read: configuration can publish its models while
|
||||
// asynchronous setup is running.
|
||||
const updates = (async () => {
|
||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
||||
if (event.location) {
|
||||
const workspaceID =
|
||||
"workspaceID" in event.location
|
||||
? event.location.workspaceID
|
||||
: undefined;
|
||||
if (
|
||||
event.location.directory !== context.location.directory ||
|
||||
workspaceID !== context.location.workspaceID
|
||||
)
|
||||
continue;
|
||||
}
|
||||
if (event.type === "model.updated") {
|
||||
models = await discover(context, lunaPriority);
|
||||
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;
|
||||
permissionsChanged(inherited);
|
||||
permissionKey = key;
|
||||
await context.agent.reload();
|
||||
} else if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted" ||
|
||||
event.type === "session.idle"
|
||||
) {
|
||||
await quota.complete(event.data.sessionID);
|
||||
} else if (event.type === "credential.updated") {
|
||||
await quota.authChanged();
|
||||
} else if (
|
||||
event.type === "credential.switched" &&
|
||||
String(event.data.integrationID) === "openai"
|
||||
) {
|
||||
await quota.authChanged(true);
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
console.error("stanp-opencode model update failed", error);
|
||||
});
|
||||
models = await discover(context, lunaPriority);
|
||||
const { data: initialAgents } = await context.agent.list();
|
||||
inherited =
|
||||
initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
|
||||
permissionsChanged(inherited);
|
||||
permissionKey = JSON.stringify(inherited);
|
||||
const assignments =
|
||||
selected.mode === "gpt"
|
||||
? Object.fromEntries(
|
||||
workerNames.map((name) => [
|
||||
name,
|
||||
{
|
||||
...gptAgents[name],
|
||||
provider: selected.provider,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: selected.agents;
|
||||
let workers: (typeof workerNames)[number][] = [];
|
||||
const active = new Set<string>();
|
||||
const playwrightCommand = selected.playwright
|
||||
? await playwrightMcpCommand(context.location.directory)
|
||||
: undefined;
|
||||
|
||||
await context.skill.transform((editor) => {
|
||||
if (editor.get("anti-overengineering")) return;
|
||||
editor.add({
|
||||
id: Skill.ID.make("anti-overengineering"),
|
||||
name: Skill.Name.make("anti-overengineering"),
|
||||
description:
|
||||
"Build the smallest correct solution without sacrificing correctness.",
|
||||
path: AbsolutePath.make(fileURLToPath(import.meta.url)),
|
||||
content: antiOverengineering,
|
||||
});
|
||||
});
|
||||
|
||||
await context.mcp.transform((editor) => {
|
||||
if (playwrightCommand && !editor.get("playwright"))
|
||||
editor.set("playwright", {
|
||||
type: "local",
|
||||
command: playwrightCommand,
|
||||
timeout: { startup: 300_000 },
|
||||
});
|
||||
hasCodegraph = editor.get("codegraph") !== undefined;
|
||||
if (hasCodegraph)
|
||||
editor.update("codegraph", (server) => {
|
||||
server.codemode = false;
|
||||
});
|
||||
});
|
||||
await context.tool.transform((editor) => {
|
||||
const next = editor
|
||||
.list()
|
||||
.map((tool) => tool.id)
|
||||
.filter((id) => id.startsWith("codegraph_"))
|
||||
.sort();
|
||||
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||
codegraphTools = next;
|
||||
if (agentTransformReady)
|
||||
queueMicrotask(() => {
|
||||
void context.agent
|
||||
.reload()
|
||||
.catch((error) =>
|
||||
console.error("stanp-opencode agent reload failed", error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await context.agent.transform((editor) => {
|
||||
const coordinates = (assignment: { model: string }) => {
|
||||
if (selected.mode === "gpt")
|
||||
return { provider: selected.provider, model: assignment.model };
|
||||
const slash = assignment.model.indexOf("/");
|
||||
return {
|
||||
provider: assignment.model.slice(0, slash),
|
||||
model: assignment.model.slice(slash + 1),
|
||||
};
|
||||
};
|
||||
const availableFor = (assignment: { model: string }) => {
|
||||
const { provider, model } = coordinates(assignment);
|
||||
return models.get(modelKey(provider, model));
|
||||
};
|
||||
const deepLo = assignments["deep-lo"];
|
||||
const deep =
|
||||
deepLo !== undefined &&
|
||||
assignments["deep-hi"] !== undefined &&
|
||||
availableFor(deepLo) !== undefined;
|
||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||
const coordinate = deep ? deepLo : assignments["good-lo"];
|
||||
workers = [coordinate, ...candidates.map((name) => assignments[name])].every(
|
||||
(assignment) =>
|
||||
assignment !== undefined && availableFor(assignment) !== undefined,
|
||||
)
|
||||
? 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 = availableFor(assignment)!;
|
||||
editor.update(name, (agent) => {
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === assignment.effort,
|
||||
);
|
||||
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),
|
||||
};
|
||||
if (hasVariant)
|
||||
agent.model.variant = Model.VariantID.make(assignment.effort);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: assignment.effort },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.permissions = [
|
||||
...structuredClone(inherited),
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
];
|
||||
});
|
||||
}
|
||||
editor.update("coordinate", (agent) => {
|
||||
const available = availableFor(coordinate)!;
|
||||
const hasVariant = available.model.variants.some(
|
||||
({ id }) => id === coordinate.effort,
|
||||
);
|
||||
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),
|
||||
};
|
||||
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort);
|
||||
agent.request = {
|
||||
settings: hasVariant ? {} : { reasoningEffort: coordinate.effort },
|
||||
headers: {},
|
||||
body: {},
|
||||
};
|
||||
if (lunaPriority && available.serviceTier)
|
||||
agent.request.settings.serviceTier = available.serviceTier;
|
||||
agent.system = routingGuide(deep);
|
||||
agent.permissions = [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
...reading.flatMap((action) =>
|
||||
inherited
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
),
|
||||
...(hasCodegraph
|
||||
? codegraphTools.flatMap((action) =>
|
||||
inherited
|
||||
.filter((rule) => matches(action, rule.action))
|
||||
.map((rule) => ({ ...rule, action })),
|
||||
)
|
||||
: []),
|
||||
...workers.map((resource) => ({
|
||||
action: "subagent",
|
||||
resource,
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
];
|
||||
});
|
||||
});
|
||||
agentTransformReady = true;
|
||||
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'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.session.hook("model.request", async (event) =>
|
||||
quota.start(event.sessionID),
|
||||
);
|
||||
await context.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "subagent") return;
|
||||
const { agent } = delegation.parse(event.input);
|
||||
if (agent === "multi-work") throw new Error("multi-work is primary only");
|
||||
if (agent === "coordinate" && event.agent !== "multi-work")
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
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 === "multi-work") throw new Error("multi-work is primary only");
|
||||
const parent = await context.session.get({ sessionID: child.parentID });
|
||||
if (child.agent === "coordinate") {
|
||||
const binding = bindingSchema.safeParse(
|
||||
await context.storage.get(bindingKey(event.sessionID)),
|
||||
);
|
||||
if (
|
||||
!binding.success ||
|
||||
!binding.data.ready ||
|
||||
binding.data.child !== event.sessionID ||
|
||||
binding.data.owner !== child.parentID ||
|
||||
binding.data.projectID !== child.projectID ||
|
||||
binding.data.projectID !== parent.projectID ||
|
||||
parent.agent !== "multi-work" ||
|
||||
child.location.directory !== binding.data.directory ||
|
||||
parent.location.directory !== binding.data.root.directory
|
||||
)
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
}
|
||||
if (
|
||||
parent.agent === "multi-work" &&
|
||||
child.agent !== "coordinate" &&
|
||||
(child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory)
|
||||
)
|
||||
throw new Error(
|
||||
"multi-work may delegate only to local fast-lo or a bound coordinate child",
|
||||
);
|
||||
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;
|
||||
await quota.dispose();
|
||||
await quotaRegistration?.dispose();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const basePlugin = makeBasePlugin(() => {});
|
||||
|
||||
export default EffectPlugin.define({
|
||||
id: "stanp-opencode",
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
let inherited: Agent.Info["permissions"] = Agent.Info.default(
|
||||
Agent.ID.make("build"),
|
||||
).permissions;
|
||||
yield* fromPromise(
|
||||
makeBasePlugin((permissions) => {
|
||||
inherited = permissions;
|
||||
}),
|
||||
).effect(context);
|
||||
yield* installMultiWork(context, () => inherited);
|
||||
}),
|
||||
});
|
||||
+67
-2
@@ -3,7 +3,9 @@ import { Tool } from "@opencode/schema/tool";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { AbsolutePath } from "@opencode/schema/schema";
|
||||
import type { Context } from "@opencode/plugin/effect/plugin";
|
||||
import { Cause, Effect, Schedule } from "effect";
|
||||
import { Plugin } from "@opencode/plugin/effect";
|
||||
import { AgentsRpc } from "./agents-rpc.js";
|
||||
import { Cause, Effect, Schedule, Stream } from "effect";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Herdr, rootSchema } from "./herdr.js";
|
||||
@@ -97,7 +99,7 @@ export const installMultiWork = (
|
||||
agent.system = `${agent.system ?? ""}\n${childProtocol}`;
|
||||
});
|
||||
if (!eligible) return;
|
||||
// The base plugin captures the final registry after late configuration
|
||||
// Agents captures the final registry after late configuration
|
||||
// transforms and reloads when it changes. Editor.build is too early.
|
||||
const inherited = buildPermissions();
|
||||
editor.update("multi-work", (agent) => {
|
||||
@@ -156,6 +158,34 @@ export const installMultiWork = (
|
||||
return binding;
|
||||
});
|
||||
|
||||
// Commands can create children without going through the subagent tool.
|
||||
yield* context.session.hook("prompt", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const child = yield* context.session.get({ sessionID: event.sessionID });
|
||||
if (!child.parentID) return;
|
||||
if (child.agent === "multi-work") throw new Error("multi-work is primary only");
|
||||
const parent = yield* context.session.get({ sessionID: child.parentID });
|
||||
if (child.agent === "coordinate") yield* validate(event.sessionID);
|
||||
if (
|
||||
parent.agent === "multi-work" &&
|
||||
child.agent !== "coordinate" &&
|
||||
(child.agent !== "fast-lo" ||
|
||||
child.location.directory !== parent.location.directory)
|
||||
)
|
||||
throw new Error(
|
||||
"multi-work may delegate only to local fast-lo or a bound coordinate child",
|
||||
);
|
||||
}).pipe(Effect.orDie),
|
||||
);
|
||||
yield* context.tool.hook("execute.before", (event) => {
|
||||
if (
|
||||
event.tool === "subagent" &&
|
||||
z.object({ agent: z.string() }).parse(event.input).agent === "multi-work"
|
||||
)
|
||||
return Effect.fail(new Tool.Error({ message: "multi-work is primary only" }));
|
||||
return Effect.void;
|
||||
});
|
||||
|
||||
yield* context.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* context.session.get({ sessionID: event.sessionID });
|
||||
@@ -489,3 +519,38 @@ export const installMultiWork = (
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default Plugin.define({
|
||||
id: "stanp-opencode.multi-work",
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = context.rpc(AgentsRpc);
|
||||
let inherited = yield* agents
|
||||
.permissions({})
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.die(new Error("Multi-work requires Agents loaded before it")),
|
||||
),
|
||||
);
|
||||
yield* context.event.subscribe().pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.type === "agent.updated" &&
|
||||
(!event.location ||
|
||||
(event.location.directory === context.location.directory &&
|
||||
event.location.workspaceID === context.location.workspaceID)),
|
||||
),
|
||||
Stream.runForEach(() =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* agents.permissions({});
|
||||
if (JSON.stringify(next) === JSON.stringify(inherited)) return;
|
||||
inherited = next;
|
||||
yield* context.agent.reload();
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.forkScoped,
|
||||
);
|
||||
yield* installMultiWork(context, () => inherited);
|
||||
}).pipe(Effect.orDie),
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { access, readFile, realpath } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { dirname, join, parse, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
const dockerCommand = ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"];
|
||||
const directPackageManifest = z.object({
|
||||
name: z.literal("@playwright/mcp"),
|
||||
bin: z.string(),
|
||||
});
|
||||
const namedPackageManifest = z.object({
|
||||
name: z.literal("@playwright/mcp"),
|
||||
bin: z.object({ "playwright-mcp": z.string() }),
|
||||
});
|
||||
const playwrightPackageManifest = z.object({
|
||||
name: z.literal("playwright"),
|
||||
bin: z.object({ playwright: z.string() }),
|
||||
});
|
||||
const playwrightTestPackageManifest = z.object({
|
||||
name: z.literal("@playwright/test"),
|
||||
bin: z.object({ playwright: z.string() }),
|
||||
dependencies: z.object({ playwright: z.string() }),
|
||||
});
|
||||
|
||||
async function packageExecutable(packageRoot: string) {
|
||||
try {
|
||||
const input = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
||||
const direct = directPackageManifest.safeParse(input);
|
||||
const bin = direct.success
|
||||
? direct.data.bin
|
||||
: namedPackageManifest.parse(input).bin["playwright-mcp"];
|
||||
const executable = resolve(packageRoot, bin);
|
||||
await access(executable, constants.X_OK);
|
||||
return executable;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function ordinaryPackageExecutable(packageRoot: string) {
|
||||
try {
|
||||
const manifest = playwrightPackageManifest.parse(
|
||||
JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")),
|
||||
);
|
||||
const executable = resolve(packageRoot, manifest.bin.playwright);
|
||||
await access(executable, constants.X_OK);
|
||||
return executable;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function playwrightDependency(packageRoot: string) {
|
||||
let current = await realpath(packageRoot);
|
||||
const root = parse(current).root;
|
||||
while (true) {
|
||||
const executable = await ordinaryPackageExecutable(
|
||||
join(current, "node_modules/playwright"),
|
||||
);
|
||||
if (executable) return executable;
|
||||
if (current === root) return;
|
||||
current = dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
async function testPackageExecutable(packageRoot: string) {
|
||||
try {
|
||||
const manifest = playwrightTestPackageManifest.parse(
|
||||
JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")),
|
||||
);
|
||||
if (!(await playwrightDependency(packageRoot))) return;
|
||||
const executable = resolve(packageRoot, manifest.bin.playwright);
|
||||
await access(executable, constants.X_OK);
|
||||
return executable;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifiedPathExecutable(candidate: string, kind: "mcp" | "playwright") {
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
const target = await realpath(candidate);
|
||||
let directory = dirname(target);
|
||||
const root = parse(directory).root;
|
||||
while (true) {
|
||||
const executable =
|
||||
kind === "mcp"
|
||||
? await packageExecutable(directory)
|
||||
: ((await ordinaryPackageExecutable(directory)) ??
|
||||
(await testPackageExecutable(directory)));
|
||||
if (executable && (await realpath(executable)) === target) return candidate;
|
||||
if (directory === root) return;
|
||||
directory = dirname(directory);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playwrightMcpCommand(
|
||||
directory: string,
|
||||
path = process.env.PATH ?? "",
|
||||
): Promise<string[]> {
|
||||
let current = resolve(directory);
|
||||
const root = parse(current).root;
|
||||
while (true) {
|
||||
const executable = await packageExecutable(join(current, "node_modules/@playwright/mcp"));
|
||||
if (executable) return [executable];
|
||||
if (current === root) break;
|
||||
current = dirname(current);
|
||||
}
|
||||
|
||||
for (const entry of path.split(process.platform === "win32" ? ";" : ":")) {
|
||||
if (!entry) continue;
|
||||
const executable = await verifiedPathExecutable(join(entry, "playwright-mcp"), "mcp");
|
||||
if (executable) return [executable];
|
||||
}
|
||||
|
||||
current = resolve(directory);
|
||||
while (true) {
|
||||
const playwright = await ordinaryPackageExecutable(
|
||||
join(current, "node_modules/playwright"),
|
||||
);
|
||||
const test = await testPackageExecutable(join(current, "node_modules/@playwright/test"));
|
||||
if (playwright || test) return ["npx", "-y", "@playwright/mcp@latest"];
|
||||
if (current === root) break;
|
||||
current = dirname(current);
|
||||
}
|
||||
|
||||
for (const entry of path.split(process.platform === "win32" ? ";" : ":")) {
|
||||
if (!entry) continue;
|
||||
const executable = await verifiedPathExecutable(join(entry, "playwright"), "playwright");
|
||||
if (executable) return ["npx", "-y", "@playwright/mcp@latest"];
|
||||
}
|
||||
return [...dockerCommand];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Plugin } from "@opencode/plugin";
|
||||
import { QuotaTracker } from "./quota.js";
|
||||
import { QuotaRpc } from "./quota-rpc.js";
|
||||
|
||||
export default Plugin.define({
|
||||
id: "stanp-opencode.quota",
|
||||
async setup(context) {
|
||||
const controller = new AbortController();
|
||||
const quota = new QuotaTracker(context, async () => {
|
||||
await registration.events.emit("updated", {});
|
||||
});
|
||||
const registration = await context.rpc.register(QuotaRpc, {
|
||||
status: async () => quota.status() ?? null,
|
||||
});
|
||||
await quota.init();
|
||||
const updates = (async () => {
|
||||
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
||||
if (
|
||||
event.location &&
|
||||
(event.location.directory !== context.location.directory ||
|
||||
("workspaceID" in event.location
|
||||
? event.location.workspaceID
|
||||
: undefined) !== context.location.workspaceID)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted" ||
|
||||
event.type === "session.idle"
|
||||
) {
|
||||
await quota.complete(event.data.sessionID);
|
||||
} else if (event.type === "credential.updated") {
|
||||
await quota.authChanged();
|
||||
} else if (
|
||||
event.type === "credential.switched" &&
|
||||
String(event.data.integrationID) === "openai"
|
||||
) {
|
||||
await quota.authChanged(true);
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
console.error("stanp-opencode quota update failed", error);
|
||||
});
|
||||
await context.session.hook("model.request", async (event) => quota.start(event.sessionID));
|
||||
return async () => {
|
||||
controller.abort();
|
||||
await updates;
|
||||
await quota.dispose();
|
||||
await registration.dispose();
|
||||
};
|
||||
},
|
||||
});
|
||||
+1
-14
@@ -75,7 +75,6 @@ const storedState = z.object({
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
const legacyStoredState = storedState.extend({ sessions: z.unknown() });
|
||||
type StoredState = z.infer<typeof storedState>;
|
||||
|
||||
function withoutExcludedState(state: StoredState): StoredState {
|
||||
@@ -223,7 +222,6 @@ export class QuotaTracker {
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private request: Promise<QuotaSnapshot | undefined> | undefined;
|
||||
private readonly requests = new Set<Promise<QuotaSnapshot | undefined>>();
|
||||
private readonly migrations = new Set<Promise<void>>();
|
||||
private readonly active = new Set<string>();
|
||||
private controller: AbortController | undefined;
|
||||
private generation = 0;
|
||||
@@ -263,16 +261,6 @@ export class QuotaTracker {
|
||||
const stored = parsed.success ? parsed.data : undefined;
|
||||
if (generation !== this.generation) return;
|
||||
this.state = stored?.account === account ? withoutExcludedState(stored) : { account };
|
||||
const legacy = legacyStoredState.safeParse(value);
|
||||
if (stored?.account === account && legacy.success) {
|
||||
const migration = this.persist();
|
||||
this.migrations.add(migration);
|
||||
try {
|
||||
await migration;
|
||||
} finally {
|
||||
this.migrations.delete(migration);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return;
|
||||
if (!this.timer) this.timer = setInterval(() => void this.refresh(), this.pollMs);
|
||||
@@ -395,7 +383,6 @@ export class QuotaTracker {
|
||||
this.controller = undefined;
|
||||
this.request = undefined;
|
||||
this.active.clear();
|
||||
await Promise.all(this.migrations);
|
||||
if (this.state) await this.clear();
|
||||
}
|
||||
await this.refresh();
|
||||
@@ -407,6 +394,6 @@ export class QuotaTracker {
|
||||
this.controller?.abort();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
await Promise.all([...this.requests, ...this.migrations]);
|
||||
await Promise.all(this.requests);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user