557 lines
30 KiB
TypeScript
557 lines
30 KiB
TypeScript
import { Agent } from "@opencode/schema/agent";
|
|
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 { 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";
|
|
import { multiWorkGuide } from "./routing.js";
|
|
|
|
export const bindingSchema = z
|
|
.object({
|
|
owner: z.string(),
|
|
child: z.string(),
|
|
projectID: z.string(),
|
|
root: rootSchema,
|
|
directory: z.string(),
|
|
workspaceID: z.string(),
|
|
branch: z.string(),
|
|
ready: z.boolean(),
|
|
})
|
|
.refine(
|
|
(binding) =>
|
|
binding.directory === binding.root.directory
|
|
? binding.workspaceID === binding.root.workspaceID && binding.branch === "root"
|
|
: binding.workspaceID !== binding.root.workspaceID,
|
|
{ message: "Invalid multi-work placement metadata" },
|
|
);
|
|
export const bindingKey = (id: string) => `multi-work:${id}`;
|
|
const inputSchema = z
|
|
.object({
|
|
agent: z.string(),
|
|
description: z.string(),
|
|
prompt: z.string(),
|
|
sessionID: z.string().optional(),
|
|
background: z.boolean().optional(),
|
|
worktree: z
|
|
.object({
|
|
base: z.string().trim().min(1),
|
|
branch: z.string().trim().min(1).optional(),
|
|
label: z.string().trim().min(1).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
const readTools = new Set([
|
|
"read",
|
|
"glob",
|
|
"grep",
|
|
"list",
|
|
"todoread",
|
|
"todowrite",
|
|
"webfetch",
|
|
"websearch",
|
|
"question",
|
|
"subagent",
|
|
]);
|
|
export const childProtocol =
|
|
"Bound child sessions use stanp-opencode multi-work protocol 1 and must remain in their assigned worktree.";
|
|
|
|
function matches(value: string, pattern: string) {
|
|
return new RegExp(
|
|
`^${pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".")}$`,
|
|
"s",
|
|
).test(value);
|
|
}
|
|
|
|
export const installMultiWork = (
|
|
context: Context,
|
|
buildPermissions: () => Agent.Info["permissions"],
|
|
herdr = new Herdr(),
|
|
) =>
|
|
Effect.gen(function* () {
|
|
let eligible = false;
|
|
const busy = new Set<string>();
|
|
const refresh = Effect.gen(function* () {
|
|
const root = yield* Effect.tryPromise((signal) =>
|
|
herdr.root(context.location.directory, signal),
|
|
).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
const next = root !== undefined;
|
|
if (next === eligible) return;
|
|
eligible = next;
|
|
yield* context.agent.reload();
|
|
});
|
|
yield* refresh;
|
|
yield* context.agent.transform((editor) => {
|
|
editor.remove("multi-work");
|
|
const coordinate = editor.get("coordinate");
|
|
if (!coordinate || !editor.get("fast-lo")) return;
|
|
editor.update("coordinate", (agent) => {
|
|
agent.mode = "all";
|
|
agent.system = `${agent.system ?? ""}\n${childProtocol}`;
|
|
});
|
|
if (!eligible) return;
|
|
// 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) => {
|
|
Object.assign(agent, structuredClone(coordinate));
|
|
agent.id = Agent.ID.make("multi-work");
|
|
agent.name = Agent.Name.make("multi-work");
|
|
agent.mode = "primary";
|
|
agent.description =
|
|
"Coordinate independent tasks locally or in Herdr worktrees; retrieve local facts with fast-lo.";
|
|
agent.system = multiWorkGuide;
|
|
agent.permissions = [
|
|
...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
|
|
{ action: "subagent", resource: "fast-lo", effect: "allow" },
|
|
{ action: "subagent", resource: "coordinate", effect: "allow" },
|
|
...inherited
|
|
.filter((rule) => matches("herdr_worktree", rule.action))
|
|
.map((rule) => ({ ...rule, action: "herdr_worktree" })),
|
|
];
|
|
});
|
|
});
|
|
yield* refresh.pipe(Effect.repeat(Schedule.spaced("30 seconds")), Effect.forkScoped);
|
|
yield* context.tool.hook("execute.before", (event) => {
|
|
if (
|
|
event.agent === "multi-work" &&
|
|
!readTools.has(event.tool) &&
|
|
!event.tool.startsWith("codegraph_")
|
|
)
|
|
return Effect.fail(
|
|
new Tool.Error({ message: "multi-work may only read, plan, and delegate" }),
|
|
);
|
|
return Effect.void;
|
|
});
|
|
|
|
const validate = (sessionID: Parameters<Context["session"]["get"]>[0]["sessionID"]) =>
|
|
Effect.gen(function* () {
|
|
const child = yield* context.session.get({ sessionID });
|
|
const binding = bindingSchema.parse(
|
|
yield* context.storage.get(bindingKey(sessionID)),
|
|
);
|
|
if (
|
|
!binding.ready ||
|
|
binding.child !== sessionID ||
|
|
child.parentID !== binding.owner ||
|
|
child.agent !== "coordinate" ||
|
|
child.projectID !== binding.projectID ||
|
|
child.location.directory !== binding.directory
|
|
)
|
|
throw new Error("Coordinate child does not match its ready multi-work binding");
|
|
const parent = yield* context.session.get({ sessionID: child.parentID });
|
|
if (
|
|
parent.agent !== "multi-work" ||
|
|
parent.projectID !== binding.projectID ||
|
|
parent.location.directory !== binding.root.directory
|
|
)
|
|
throw new Error("Binding owner changed agent or location");
|
|
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 });
|
|
if (event.agent === "coordinate" && session.parentID)
|
|
yield* validate(event.sessionID);
|
|
if (event.agent !== "multi-work") {
|
|
const tool = event.tools.subagent;
|
|
if (tool) {
|
|
const properties = z
|
|
.record(z.string(), z.unknown())
|
|
.parse(tool.input.properties ?? {});
|
|
delete properties.worktree;
|
|
tool.input = { ...tool.input, properties };
|
|
}
|
|
return;
|
|
}
|
|
for (const name of Object.keys(event.tools))
|
|
if (!readTools.has(name) && !name.startsWith("codegraph_"))
|
|
delete event.tools[name];
|
|
if (session.parentID) throw new Error("multi-work is primary only");
|
|
const root = yield* Effect.tryPromise((signal) =>
|
|
herdr.root(session.location.directory, signal),
|
|
);
|
|
if (!root)
|
|
throw new Error("multi-work requires a live, unambiguous Herdr root checkout");
|
|
const tool = event.tools.subagent;
|
|
if (tool) {
|
|
tool.input = z.toJSONSchema(inputSchema);
|
|
const properties = z
|
|
.record(z.string(), z.unknown())
|
|
.parse(tool.input.properties);
|
|
tool.input.properties = {
|
|
...properties,
|
|
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
|
};
|
|
tool.description =
|
|
"Delegate to fast-lo for retrieval, or coordinate in this root checkout by omitting worktree. To isolate new coordinate work, use worktree: {base, branch?, label?}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply.";
|
|
}
|
|
}).pipe(Effect.orDie),
|
|
);
|
|
|
|
yield* context.tool.transform((editor) => {
|
|
const native = editor.get("subagent");
|
|
if (!native) return;
|
|
editor.update("subagent", (tool) => {
|
|
tool.input = inputSchema;
|
|
tool.execute = (raw, call) =>
|
|
Effect.gen(function* () {
|
|
const input = inputSchema.parse(raw);
|
|
if (input.agent === "multi-work")
|
|
throw new Error("multi-work is primary only");
|
|
if (
|
|
input.sessionID &&
|
|
(yield* context.storage.get(bindingKey(input.sessionID))) !==
|
|
undefined &&
|
|
(call.agent !== "multi-work" || input.agent !== "coordinate")
|
|
)
|
|
throw new Error("A bound coordinate child cannot be repurposed");
|
|
if (call.agent !== "multi-work") {
|
|
if (input.worktree || input.agent === "coordinate")
|
|
throw new Error("Only multi-work may delegate to coordinate");
|
|
return yield* native.execute(input, call);
|
|
}
|
|
const parent = yield* context.session.get({ sessionID: call.sessionID });
|
|
if (parent.parentID || parent.agent !== "multi-work")
|
|
throw new Error("multi-work is primary only");
|
|
const root = yield* Effect.tryPromise((signal) =>
|
|
herdr.root(parent.location.directory, signal),
|
|
);
|
|
if (!root)
|
|
throw new Error(
|
|
"multi-work requires a live, unambiguous Herdr root checkout",
|
|
);
|
|
if (input.agent === "fast-lo") {
|
|
if (input.worktree) throw new Error("fast-lo is local retrieval only");
|
|
if (input.sessionID) {
|
|
const child = yield* context.session.get({
|
|
sessionID: Session.ID.make(input.sessionID),
|
|
});
|
|
if (
|
|
child.parentID !== parent.id ||
|
|
child.agent !== "fast-lo" ||
|
|
child.location.directory !== parent.location.directory
|
|
)
|
|
throw new Error("Local fast-lo child binding changed");
|
|
}
|
|
return yield* native.execute(input, call);
|
|
}
|
|
if (input.agent !== "coordinate")
|
|
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
|
if (input.sessionID && input.worktree !== undefined)
|
|
throw new Error(
|
|
"Coordinate resumes require only sessionID; worktree is create-only",
|
|
);
|
|
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
|
if (busy.has(lock)) throw new Error("This delegation is already active");
|
|
busy.add(lock);
|
|
let childLock: string | undefined;
|
|
return yield* Effect.gen(function* () {
|
|
let initialized = false;
|
|
let placement: z.infer<typeof bindingSchema> | undefined;
|
|
if (input.sessionID) {
|
|
const binding = yield* validate(Session.ID.make(input.sessionID));
|
|
if (binding.owner !== call.sessionID)
|
|
throw new Error("Cannot resume another parent's binding");
|
|
placement = binding;
|
|
}
|
|
if (
|
|
input.worktree ||
|
|
(placement && placement.directory !== placement.root.directory)
|
|
) {
|
|
const { data: agent } = yield* context.agent.get({
|
|
agentID: call.agent,
|
|
location: { directory: parent.location.directory },
|
|
});
|
|
const permission = [
|
|
...agent.permissions,
|
|
...(parent.permissions ?? []),
|
|
].findLast(
|
|
(rule) =>
|
|
matches("herdr_worktree", rule.action) &&
|
|
matches(root.directory, rule.resource),
|
|
)?.effect;
|
|
if (permission !== "allow")
|
|
throw new Error(
|
|
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
|
|
);
|
|
}
|
|
if (placement) {
|
|
const binding = placement;
|
|
if (
|
|
root.directory !== binding.root.directory ||
|
|
root.workspaceID !== binding.root.workspaceID ||
|
|
root.repo !== binding.root.repo
|
|
)
|
|
throw new Error("Bound Herdr root is no longer available");
|
|
if (binding.directory !== binding.root.directory)
|
|
yield* Effect.tryPromise((signal) =>
|
|
herdr.verify(
|
|
binding.directory,
|
|
binding.workspaceID,
|
|
binding.root,
|
|
binding.branch,
|
|
signal,
|
|
),
|
|
);
|
|
initialized = true;
|
|
}
|
|
const { worktree, ...args } = input;
|
|
const callKey = `multi-work-call:${call.sessionID}:${call.id}`;
|
|
if (!input.sessionID) {
|
|
const previous = yield* context.storage.get(callKey);
|
|
if (previous !== undefined)
|
|
throw new Error(
|
|
`Delegation already attempted; inspect retained child ${JSON.stringify(previous)} before retrying`,
|
|
);
|
|
}
|
|
const result = yield* native.execute(args, {
|
|
...call,
|
|
progress: (metadata) =>
|
|
Effect.gen(function* () {
|
|
// Native 2.0.22 awaits this update after creation and before prompt admission.
|
|
yield* call.progress(metadata);
|
|
if (initialized) return;
|
|
const id = z.string().parse(metadata.sessionID);
|
|
if (busy.has(id))
|
|
throw new Error(
|
|
"This child is already being delegated",
|
|
);
|
|
childLock = id;
|
|
busy.add(id);
|
|
const sessionID = Session.ID.make(id);
|
|
const child = yield* context.session.get({ sessionID });
|
|
if (
|
|
child.parentID !== call.sessionID ||
|
|
child.agent !== "coordinate" ||
|
|
child.projectID !== parent.projectID ||
|
|
child.location.directory !== root.directory
|
|
)
|
|
throw new Error(
|
|
"Native executor returned an unexpected child",
|
|
);
|
|
if (!worktree) {
|
|
const binding = {
|
|
owner: call.sessionID,
|
|
child: id,
|
|
projectID: parent.projectID,
|
|
root,
|
|
directory: root.directory,
|
|
workspaceID: root.workspaceID,
|
|
branch: "root",
|
|
ready: true,
|
|
};
|
|
yield* context.storage.set(callKey, {
|
|
child: id,
|
|
branch: binding.branch,
|
|
root,
|
|
});
|
|
yield* context.storage.set(bindingKey(id), binding);
|
|
placement = binding;
|
|
initialized = true;
|
|
return;
|
|
}
|
|
const branch =
|
|
worktree.branch ?? `multi-work/${randomUUID()}`;
|
|
yield* context.storage.set(callKey, {
|
|
child: id,
|
|
branch,
|
|
root,
|
|
});
|
|
yield* call.progress({ ...metadata, branch });
|
|
const destination = yield* Effect.tryPromise((signal) =>
|
|
herdr.create(
|
|
root,
|
|
worktree.base,
|
|
branch,
|
|
signal,
|
|
worktree.label,
|
|
),
|
|
);
|
|
const binding = {
|
|
owner: call.sessionID,
|
|
child: id,
|
|
projectID: parent.projectID,
|
|
root,
|
|
...destination,
|
|
ready: false,
|
|
};
|
|
yield* context.storage.set(bindingKey(id), binding);
|
|
yield* call.progress({ ...metadata, ...destination });
|
|
yield* Effect.gen(function* () {
|
|
// Cold locations activate plugins asynchronously.
|
|
yield* Effect.gen(function* () {
|
|
for (;;) {
|
|
const { data: agents } =
|
|
yield* context.agent.list({
|
|
location: {
|
|
directory: destination.directory,
|
|
},
|
|
});
|
|
const coordinator = agents.find(
|
|
(agent) => agent.id === "coordinate",
|
|
);
|
|
if (
|
|
coordinator?.mode === "all" &&
|
|
coordinator.system?.includes(
|
|
childProtocol,
|
|
) &&
|
|
[
|
|
"coordinate",
|
|
"fast-lo",
|
|
"fast-hi",
|
|
"good-lo",
|
|
"good-hi",
|
|
].every((name) =>
|
|
agents.some(
|
|
(agent) => agent.id === name,
|
|
),
|
|
)
|
|
)
|
|
return;
|
|
yield* Effect.sleep("100 millis");
|
|
}
|
|
}).pipe(Effect.timeout("30 seconds"));
|
|
yield* context.session.move({
|
|
sessionID,
|
|
directory: AbsolutePath.make(destination.directory),
|
|
});
|
|
yield* Effect.gen(function* () {
|
|
while (
|
|
(yield* context.session.get({ sessionID }))
|
|
.location.directory !==
|
|
destination.directory
|
|
)
|
|
yield* Effect.sleep("50 millis");
|
|
}).pipe(Effect.timeout("30 seconds"));
|
|
if (
|
|
(yield* context.session.get({ sessionID }))
|
|
.projectID !== parent.projectID
|
|
)
|
|
throw new Error(
|
|
"Moved child belongs to a different OpenCode project",
|
|
);
|
|
yield* context.storage.set(bindingKey(id), {
|
|
...binding,
|
|
ready: true,
|
|
});
|
|
placement = { ...binding, ready: true };
|
|
initialized = true;
|
|
}).pipe(
|
|
Effect.catchCause((cause) => {
|
|
if (Cause.hasInterrupts(cause))
|
|
return Effect.interrupt;
|
|
return Effect.fail(
|
|
new Tool.Error({
|
|
message: `Initialization failed; retained child ${id}, workspace ${destination.workspaceID}, checkout ${destination.directory}`,
|
|
error: cause,
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}).pipe(Effect.orDie),
|
|
});
|
|
if (!initialized || !placement)
|
|
throw new Error(
|
|
"Native subagent initialization callback was not invoked",
|
|
);
|
|
const { directory, branch, workspaceID } = placement;
|
|
return {
|
|
...result,
|
|
metadata: { ...result.metadata, directory, branch, workspaceID },
|
|
content: `${z.string().parse(result.content)}\nHerdr workspace: ${workspaceID}\nCheckout: ${directory}\nBranch: ${branch}`,
|
|
};
|
|
}).pipe(
|
|
Effect.ensuring(
|
|
Effect.sync(() => {
|
|
busy.delete(lock);
|
|
if (childLock) busy.delete(childLock);
|
|
}),
|
|
),
|
|
);
|
|
}).pipe(
|
|
Effect.catchCause((cause) => {
|
|
// Interruption must remain interruption so native cancellation finalizers run.
|
|
if (Cause.hasInterrupts(cause)) return Effect.interrupt;
|
|
return Effect.fail(
|
|
new Tool.Error({ message: Cause.pretty(cause), error: cause }),
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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),
|
|
});
|