mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
354 lines
14 KiB
TypeScript
354 lines
14 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>;
|
|
/**
|
|
* Put a message in the session WITHOUT starting a model turn.
|
|
*
|
|
* Optional because it only exists on hosts whose plugin API carries it
|
|
* (`SessionDomain` in `packages/plugin/src/promise/session.ts`); every call
|
|
* site probes it first.
|
|
*/
|
|
synthetic?: (input: {
|
|
sessionID: string;
|
|
text: string;
|
|
description?: string;
|
|
resume?: boolean;
|
|
}) => 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 {
|
|
/**
|
|
* Present only when this host can show the session URL to the user. See
|
|
* `createSessionUrlNotifier` and `toastPlannotatorUrl` in `cli-bridge.ts`.
|
|
*/
|
|
notifyUrl?: (input: { url: string; message: string }) => Promise<unknown>;
|
|
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";
|
|
|
|
/**
|
|
* The one line a user is shown when a Plannotator session opens on OpenCode 2.
|
|
*
|
|
* Deliberately plain and self-contained: it is the whole notice, so it has to
|
|
* name the product and carry the URL on its own.
|
|
*/
|
|
export function formatSessionUrlNotice(url: string): string {
|
|
return `Plannotator session ready: ${url}`;
|
|
}
|
|
|
|
/**
|
|
* Deliver the session URL as a VISIBLE transcript notice on OpenCode 2.
|
|
*
|
|
* Why this exists: the V2 server-plugin context exposes no `tui` domain, so
|
|
* `toastPlannotatorUrl` optional-chains to a no-op, and this client's
|
|
* `app.log` is `console.error`, which OpenCode discards under both default
|
|
* launch modes (`packages/cli/src/services/standalone.ts` spawns the service
|
|
* with `stderr: "ignore"` unless `OPENCODE_PRINT_LOGS=1`). A remote session
|
|
* suppresses the browser and prints its URL into that discarded stream, so the
|
|
* user saw nothing at all and the command read as a hang.
|
|
*
|
|
* `session.synthetic` is the fix. Verified against anomalyco/opencode
|
|
* `origin/v2`:
|
|
* - It is on the plugin's own `SessionDomain`
|
|
* (`packages/plugin/src/promise/session.ts`).
|
|
* - `resume: false` skips the wake, so nothing starts a model turn:
|
|
* `if (input.resume !== false && !(yield* get(sessionID)).revert) yield*
|
|
* execution.wake(sessionID)` (`packages/core/src/session/session.ts`).
|
|
* Upstream's own Plan-mode reminders use exactly this shape
|
|
* (`packages/core/src/plugin/plan.ts`).
|
|
* - A synthetic message is rendered ONLY when it carries a non-empty
|
|
* `description`: `reduceSessionRows` drops the row otherwise
|
|
* (`packages/tui/src/routes/session/rows.ts`, pinned upstream by
|
|
* "hides synthetic messages without descriptions"), and the live append
|
|
* subscriptions gate on `description?.trim()` too. What the TUI prints is
|
|
* the DESCRIPTION, not the text (`SessionNoticeMessageV2` in
|
|
* `packages/tui/src/routes/session/index.tsx`), so the URL must be in both:
|
|
* `description` to be seen, `text` so the next turn's history carries it.
|
|
* - Setting no `metadata.source` keeps it on the plain "Notice" row rather
|
|
* than the subagent/shell completion row.
|
|
*
|
|
* `delivery` is an explicit "queue", mirroring `FEEDBACK_DELIVERY` (#1459).
|
|
* The host default resolves to "steer", and a pending steer row is promoted
|
|
* FIRST by any wake, including spurious idle wakes observed on OpenCode 2
|
|
* betas where `resume: false` defers the immediate wake but a later wake
|
|
* turns the notice into its own model turn. Queue delivery keeps the notice
|
|
* out of every steer-scoped promotion and is a no-op on well-behaved hosts.
|
|
*
|
|
* This does not contradict the reason feedback avoids synthetic injection.
|
|
* Upstream #44788 is about a synthetic message not reliably reaching the MODEL
|
|
* prompt, which is fatal for feedback and irrelevant here: the only claim this
|
|
* makes is that the row is rendered, and the row is rendered from committed
|
|
* message state by `reduceSessionRows`, not from the model's context.
|
|
*
|
|
* Returns undefined on an older host with no `synthetic`, or with no session to
|
|
* post into, in which case the caller falls back to today's log-only behavior.
|
|
*/
|
|
export function createSessionUrlNotifier(
|
|
ctx: V2ContextLike,
|
|
sessionID: string | undefined,
|
|
): ((input: { url: string; message: string }) => Promise<unknown>) | undefined {
|
|
const synthetic = ctx.session?.synthetic;
|
|
if (typeof synthetic !== "function" || !sessionID) return undefined;
|
|
return async ({ url }) => {
|
|
const notice = formatSessionUrlNotice(url);
|
|
return await synthetic({
|
|
sessionID,
|
|
text: notice,
|
|
description: notice,
|
|
resume: false,
|
|
// #1459: never ride the "steer" default; see the delivery note above.
|
|
delivery: FEEDBACK_DELIVERY,
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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. `notifyUrl`
|
|
* is the replacement seam for the one message that must actually be seen.
|
|
*/
|
|
export function createV2BridgeClient(input: {
|
|
ctx: V2ContextLike;
|
|
getAgents: () => Promise<OpenCodeBridgeAgent[]>;
|
|
/**
|
|
* The session this invocation belongs to. Without it there is nowhere to post
|
|
* a transcript notice, and the URL falls back to the log.
|
|
*/
|
|
sessionID?: string;
|
|
/** 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>();
|
|
const notifyUrl = createSessionUrlNotifier(input.ctx, input.sessionID);
|
|
return {
|
|
...(notifyUrl && { notifyUrl }),
|
|
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,
|
|
});
|
|
},
|
|
},
|
|
};
|
|
}
|