mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
163e9f5b8b
OpenCode's V2 plugin API gained native command execution upstream (anomalyco/opencode issue #2185, PR #44765): ctx.command.transform lets a plugin add a command whose execute callback fully owns the invocation. That shape currently ships on the beta and dev dist-tags of @opencode-ai/plugin while next and latest still carry the older context, so the capability is duck-typed at runtime and never imported. On a host that exposes it the V2 adapter registers /plannotator-review, /plannotator-annotate and /plannotator-last and runs the same handleCliCommand machinery OpenCode 1 uses, passing the raw argument tail straight through to the CLI. On a host without it nothing new is registered and behavior is byte-identical to before. Also wires ctx.session.switchAgent (same API generation, same probe) so an agent switch chosen in the review UI is applied instead of only warned about, and accepts both agent.list() response shapes: the HTTP client types it as a { location, data } envelope while the in-process plugin domain answers with a bare array, where reading .data threw and silently emptied the agent list. The shared command stubs get model-mediated fallback bodies for OpenCode 2 hosts on the stale channels. They carry no shell interpolation on purpose: OpenCode 1 evaluates a template's !`...` before the V1 plugin's command.execute.before hook can clear the parts, so a bang template there would launch a second Plannotator session on every OC1 invocation. A source level test pins that. AI-assisted (Claude) under maintainer direction.
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import { supportsSwitchAgent, type V2ContextLike } from "./v2-client";
|
|
|
|
export interface OpenCodeAgentLike {
|
|
name?: string;
|
|
}
|
|
|
|
interface OpenCodeClientLike {
|
|
app?: {
|
|
agents?: (input?: unknown) => Promise<{ data?: OpenCodeAgentLike[] }>;
|
|
log?: (entry: { level: "info" | "error"; message: string }) => unknown;
|
|
};
|
|
tui?: {
|
|
showToast?: (input: unknown) => unknown;
|
|
};
|
|
}
|
|
|
|
/** What the omitted agent switch would have applied to, used in the warning copy. */
|
|
export type AgentSwitchDelivery = "feedback" | "plan-approval";
|
|
|
|
export function resolveTargetAgent(agentSwitch?: string): string | undefined {
|
|
const trimmed = agentSwitch?.trim();
|
|
return trimmed && trimmed !== "disabled" ? trimmed : undefined;
|
|
}
|
|
|
|
function warnAgentUnavailable(
|
|
client: OpenCodeClientLike,
|
|
targetAgent: string,
|
|
delivery: AgentSwitchDelivery,
|
|
): void {
|
|
const action = delivery === "plan-approval" ? "approving the plan" : "sending feedback";
|
|
const message = `Configured OpenCode agent "${targetAgent}" is not available; ${action} without switching agents.`;
|
|
|
|
try {
|
|
void client.app?.log?.({ level: "info", message: `[Plannotator] ${message}` });
|
|
} catch {
|
|
// OpenCode logging is best-effort.
|
|
}
|
|
|
|
try {
|
|
const result = client.tui?.showToast?.({
|
|
body: { title: "Plannotator", message, variant: "warning" },
|
|
});
|
|
if (result && typeof (result as Promise<unknown>).catch === "function") {
|
|
(result as Promise<unknown>).catch(() => {});
|
|
}
|
|
} catch {
|
|
// Toast delivery is best-effort.
|
|
}
|
|
}
|
|
|
|
export async function resolveValidatedTargetAgent(input: {
|
|
client: OpenCodeClientLike;
|
|
targetAgent?: string;
|
|
directory?: string;
|
|
delivery?: AgentSwitchDelivery;
|
|
}): Promise<string | undefined> {
|
|
const targetAgent = resolveTargetAgent(input.targetAgent);
|
|
if (!targetAgent) return undefined;
|
|
|
|
try {
|
|
const response = await input.client.app?.agents?.({
|
|
query: { directory: input.directory },
|
|
});
|
|
const agents = response?.data ?? [];
|
|
if (agents.some((agent) => agent.name === targetAgent)) {
|
|
return targetAgent;
|
|
}
|
|
} catch {
|
|
// Treat validation failures as unavailable: better to omit the agent than
|
|
// send a stale/invalid target that OpenCode may reject.
|
|
}
|
|
|
|
warnAgentUnavailable(input.client, targetAgent, input.delivery ?? "feedback");
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* OpenCode 2 agent switch.
|
|
*
|
|
* `ctx.session.switchAgent` arrived with the same plugin-API generation as
|
|
* native command execution, so it is duck-typed rather than imported: on a host
|
|
* without it the plan is still approved and the caller is told the switch was
|
|
* skipped. Returns the agent actually switched to, or undefined when the
|
|
* session's agent was left alone.
|
|
*/
|
|
export async function switchV2SessionAgent(input: {
|
|
ctx: V2ContextLike;
|
|
sessionID: string;
|
|
requestedAgent?: string;
|
|
getAgents: () => Promise<OpenCodeAgentLike[]>;
|
|
warn?: (message: string) => void;
|
|
}): Promise<string | undefined> {
|
|
const warn = input.warn ?? ((message: string) => console.error(message));
|
|
const targetAgent = resolveTargetAgent(input.requestedAgent);
|
|
if (!targetAgent) return undefined;
|
|
|
|
const available = (await input.getAgents()).some((agent) => agent.name === targetAgent);
|
|
if (!available) {
|
|
warn(`[Plannotator] Configured OpenCode agent "${targetAgent}" is not available; approving the plan without switching agents.`);
|
|
return undefined;
|
|
}
|
|
|
|
if (!supportsSwitchAgent(input.ctx)) {
|
|
warn("[Plannotator] This OpenCode 2 host does not expose agent switching to plugins; approving the plan without switching agents.");
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
await input.ctx.session!.switchAgent!({ sessionID: input.sessionID, agent: targetAgent });
|
|
} catch (error) {
|
|
warn(`[Plannotator] Could not switch the OpenCode session to "${targetAgent}": ${error instanceof Error ? error.message : String(error)}`);
|
|
return undefined;
|
|
}
|
|
|
|
return targetAgent;
|
|
}
|