Files
backnotprop__plannotator/apps/opencode-plugin/v2-client.ts
Michael Ramos 82a8f236ec feat(opencode): restore the slash commands on OpenCode 2 (#1434)
* feat(opencode): restore the slash commands on OpenCode 2

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.

* fix(opencode): probe the command draft and reclaim the names from the stubs

Review found the capability probe was wrong in the direction that matters.
ctx.command.transform exists on pre-#44765 hosts too: our own pinned
@opencode-ai/plugin@0.0.0-next-16775 declares CommandDraft as
{ list, get, update, remove } with no add. The probe therefore returned true on
next and latest, draft.add was undefined, and because transforms are stored and
replayed the TypeError landed in the batched reload flush and aborted it before
commit, plausibly taking every command registration on the host down with it.
Capability is now read from the draft handed to the callback, which is the only
witness, and the registration call is wrapped so no transform rejection can fail
plugin setup.

The stubs also shadowed the native definitions on new hosts. Command definitions
land in a name-keyed map where add is Map.set, transforms replay in registration
order, and OpenCode's own ConfigCommandPlugin activates in the post group after
package plugins while scanning the exact directory the installer writes the
three stubs to. A setup-time registration is therefore always overwritten on a
normal install. The plugin now re-registers the same transform once activation
settles, so its definitions are last in the replay order, and calls
ctx.command.reload() explicitly because a late registration only adds its reload
to the already-flushed boot batch. Ownership is read back from
ctx.command.list() by description, which is why the native descriptions and the
stub frontmatter are deliberately distinct. If the reclaim cannot run the stubs
keep the names and the commands still work through their fallback bodies.

Also: a failing switchAgent no longer costs the reviewer their feedback on the
command path, feedback is delivered as "queue" rather than replaying the
invocation's admission mode minutes later when a steer would land mid-turn, and
the agent-list comment no longer asserts a bare-array response that could not be
reproduced upstream (accepting both shapes is still right, since reading .data
blindly throws into a catch that degrades silently).

Tests: the real old-host draft shape registers nothing and throws nothing, the
shadowing contest is modelled against upstream's replay semantics, the OpenCode 1
parts-clearing invariant is pinned for all three commands in both plan-agent and
manual mode now that the stubs carry real instructions, and the V2 smoke asserts
the plugin did not activate as failed and that all three commands resolve. The
smoke now also installs the stubs into its sandbox config dir so the contest
actually happens there. scripts/opencode2-native-commands-smoke.sh runs the same
smoke against a dev-channel build with native commands required; CI cannot,
because it pins a next build.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): keep the reclaim ticking and stop an unbuilt checkout failing setup

The reclaim ended the loop when the draft-probe flag read false, but that flag
only flips when the transform replays, which under boot batching is the flush
after every plugin has loaded. Plannotator loads before the post-group config
plugins, so the first tick legitimately reads false and the loop exited for
good: the reclaim was inert in exactly the shape production has. The tick is
skipped now instead, with a test that flips the flag between ticks.

The V1 entry called resolveBundledHtmlPath synchronously during plugin
construction, outside the .catch that was there to absorb a missing asset, so an
unbuilt checkout threw out of construction before any code path that needs the
HTML. The Test workflow runs bun test with no build step, so the new OpenCode 1
interception tests failed there. Both preloads are guarded; the lazy getters
still raise a clear error if something actually needs the file.

The smoke's failed-plugin guard read entry.state.status, but Plugin.Info carries
status and error at the top level, so a failed activation slipped through.
Reads the top level first and keeps the nested one as a fallback.

Comment corrections: State.batch clears its active flag before flushing, so a
late transform registration materializes on its own; the explicit reload() is
redundant-but-defensive rather than required. The reclaim schedule is a list of
deltas the loop awaits in turn, so the ticks land near 0.3s, 1.5s, 5.5s and
15.5s, not at the raw numbers.

AI-assisted (Claude) under maintainer direction.
2026-08-31 10:42:26 -07:00

252 lines
9.6 KiB
TypeScript

/**
* Duck-typed adapters over the OpenCode 2 plugin context.
*
* The V2 plugin API is still pre-release: the published `next` and `latest`
* dist-tags of `@opencode-ai/plugin` carry an older context shape than the
* `beta` / `dev` nightlies. Nothing here may import the plugin package at
* runtime or assume a domain exists: every capability is probed before use so
* the adapter degrades to today's behavior on an older host.
*/
import type { OpenCodeBridgeAgent } from "./cli-bridge";
/** The subset of the V2 session domain this plugin touches. */
export interface V2SessionDomain {
get?: (input: { sessionID: string }) => Promise<{ location?: { directory?: string } }>;
prompt?: (input: { sessionID: string; text: string; delivery?: unknown }) => Promise<unknown>;
switchAgent?: (input: { sessionID: string; agent: string }) => Promise<unknown>;
context?: (input: { sessionID: string }) => Promise<unknown>;
}
/**
* The subset of the V2 command domain this plugin touches.
*
* `transform` exists on every V2 host and says nothing about capability: the
* pre-#44765 draft is `{ list, get, update, remove }`. Only the draft handed to
* the callback can answer that, which is why nothing here treats the presence
* of `transform` as support.
*/
export interface V2CommandDomain {
transform?: (apply: (draft: V2CommandDraft) => void) => Promise<unknown> | unknown;
list?: (input?: unknown) => Promise<unknown>;
reload?: () => Promise<unknown>;
}
export interface V2ContextLike {
agent?: { list?: (input?: unknown) => Promise<unknown> };
session?: V2SessionDomain;
command?: V2CommandDomain;
location?: { directory?: string };
}
export interface V2CommandInvocation {
sessionID: string;
prompt?: { text?: string };
/**
* The admission mode OpenCode chose for the invocation. Carried for
* completeness and deliberately NOT reused when feedback comes back: see
* `FEEDBACK_DELIVERY`.
*/
delivery?: unknown;
}
export interface V2CommandDefinition {
name: string;
description?: string;
execute: (input: V2CommandInvocation) => Promise<void>;
}
/**
* Post-#44765 draft. `add` is optional in the type because an older host hands
* the callback a draft without it; every call site must probe before using it.
*/
export interface V2CommandDraft {
add?: (definition: V2CommandDefinition) => void;
}
export interface V2CommandListEntry {
name: string;
description?: string;
}
/** The V1-shaped client `cli-bridge` consumes. */
export interface V2BridgeClient {
app: {
log: (entry: { level: "info" | "error"; message: string }) => void;
agents: () => Promise<{ data: OpenCodeBridgeAgent[] }>;
};
// Widened to `unknown` on purpose: these are handed to `cli-bridge`, whose
// client interface declares the same operations with `unknown` parameters.
session: {
messages: (input: unknown) => Promise<{ data: unknown[] }>;
prompt: (input: unknown) => Promise<unknown>;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object";
}
/**
* Unwrap a list response that may or may not be enveloped.
*
* The generated client types every `list` as `{ location, data }`, and that is
* what the documented success shape is. Reading `.data` unconditionally throws
* on anything else and the throw lands in a caller's catch, where it degrades
* silently rather than loudly, so both shapes are accepted here instead.
*/
function readEntries(response: unknown): unknown[] {
if (Array.isArray(response)) return response;
if (isRecord(response) && Array.isArray(response.data)) return response.data;
return [];
}
/** Read `ctx.command.list()` into name/description pairs, envelope or not. */
export function readListPayload(response: unknown): V2CommandListEntry[] {
const entries: V2CommandListEntry[] = [];
for (const entry of readEntries(response)) {
if (!isRecord(entry) || typeof entry.name !== "string") continue;
entries.push({
name: entry.name,
description: typeof entry.description === "string" ? entry.description : undefined,
});
}
return entries;
}
/** Read an agent list, envelope or bare array, without ever throwing. */
export function normalizeAgentList(response: unknown): OpenCodeBridgeAgent[] {
const entries = readEntries(response);
const agents: OpenCodeBridgeAgent[] = [];
for (const entry of entries) {
if (!isRecord(entry)) continue;
const name = typeof entry.id === "string"
? entry.id
: typeof entry.name === "string" ? entry.name : undefined;
if (!name) continue;
agents.push({
name,
description: typeof entry.description === "string" ? entry.description : undefined,
mode: typeof entry.mode === "string" ? entry.mode : undefined,
hidden: entry.hidden === true,
});
}
return agents;
}
/** True when this host's session domain can switch the active agent. */
export function supportsSwitchAgent(ctx: V2ContextLike): boolean {
return typeof ctx.session?.switchAgent === "function";
}
// There is deliberately no `supportsNativeCommands(ctx)`. `ctx.command.transform`
// exists on hosts whose draft predates PR #44765 and has no `add`, so any probe
// from the context alone reports a false positive; the draft itself is the only
// witness. See `native-commands.ts`.
/**
* Translate `ctx.session.context()` output into the message shape
* `getRecentAssistantMessages` reads. V2 messages are flat
* (`{ id, type, time, content }`); V1 nested them under `info` / `parts`.
*/
export function toBridgeMessages(context: unknown): unknown[] {
if (!Array.isArray(context)) return [];
return context.filter(isRecord).map((message) => ({
info: {
id: typeof message.id === "string" ? message.id : undefined,
role: typeof message.type === "string" ? message.type : undefined,
time: isRecord(message.time) ? { created: message.time.created } : undefined,
},
parts: Array.isArray(message.content) ? message.content : [],
}));
}
function joinTextParts(parts: unknown[]): string {
return parts
.filter((part): part is { type: string; text: string } =>
isRecord(part) && part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
}
/** Read the session id out of the V1-shaped `{ path: { id } }` request. */
function readSessionId(request: unknown): string | undefined {
if (!isRecord(request) || !isRecord(request.path)) return undefined;
return typeof request.path.id === "string" ? request.path.id : undefined;
}
/**
* How Plannotator feedback is admitted to the session.
*
* A command invocation carries its own delivery, but that value was chosen when
* the user pressed enter, and a review comes back minutes later: replaying a
* "steer" then would land the feedback in the middle of whatever turn is
* running now. "queue" is the safe choice for a late arrival. Upstream's own
* default is "steer" (`packages/core/src/session/prompt.ts`), so this is set
* explicitly rather than omitted.
*/
const FEEDBACK_DELIVERY = "queue";
/**
* Build the V1-shaped client `handleCliCommand` and `resolveValidatedTargetAgent`
* expect, backed by the V2 context. Delivering feedback goes through
* `ctx.session.prompt`, the direct path, rather than a synthetic-event
* injection, which is unreliable on some V2 nightlies (upstream #44788).
*
* There is deliberately no `tui` domain: the V2 server-plugin context exposes
* none, and every toast call site in `cli-bridge` is best-effort.
*/
export function createV2BridgeClient(input: {
ctx: V2ContextLike;
getAgents: () => Promise<OpenCodeBridgeAgent[]>;
/** Best-effort warning sink; defaults to stderr. */
warn?: (message: string) => void;
}): V2BridgeClient {
const warn = input.warn ?? ((message: string) => console.error(message));
const loggedUrls = new Set<string>();
return {
app: {
agents: async () => ({ data: await input.getAgents() }),
log: ({ message }) => {
const url = /https?:\/\/\S+/.exec(message)?.[0];
if (url && loggedUrls.has(url)) return;
if (url) loggedUrls.add(url);
console.error(message);
},
},
session: {
messages: async (request) => {
const sessionID = readSessionId(request);
if (!sessionID) return { data: [] };
const context = await input.ctx.session?.context?.({ sessionID });
return { data: toBridgeMessages(context) };
},
prompt: async (request) => {
const sessionID = readSessionId(request);
if (!sessionID) throw new Error("Plannotator feedback has no OpenCode session to deliver to.");
const body = isRecord(request) && isRecord(request.body) ? request.body : {};
const agent = typeof body.agent === "string" ? body.agent : undefined;
if (agent && typeof input.ctx.session?.switchAgent === "function") {
// A failed switch must never cost the reviewer their feedback: the
// same guarantee `switchV2SessionAgent` gives the approval path.
try {
await input.ctx.session.switchAgent({ sessionID, agent });
} catch (error) {
warn(`[Plannotator] Could not switch the OpenCode session to "${agent}": ${error instanceof Error ? error.message : String(error)}`);
}
}
const prompt = input.ctx.session?.prompt;
if (typeof prompt !== "function") {
throw new Error("OpenCode 2 host exposes no session.prompt; cannot deliver Plannotator feedback.");
}
return await prompt({
sessionID,
text: joinTextParts(Array.isArray(body.parts) ? body.parts : []),
delivery: FEEDBACK_DELIVERY,
});
},
},
};
}