mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
affc22e730
On OpenCode 2 a plugin message is a pending inbox row; resume: false only declines the immediate wake. SessionInbox.promote publishes pending steers as a batch but queued rows one at a time, oldest first, so the queued notice #1460 introduced was promoted alone by the feedback's own wake and became its own model turn with the reviewer's annotations behind it. The notice now rides steer delivery, and feedback that follows an admitted notice rides steer too, so both enter one promotion and the agent acts on the annotations. A rejected notice, OpenCode 1, and hosts without session.synthetic keep the queue delivery unchanged.
412 lines
17 KiB
TypeScript
412 lines
17 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 NOW.
|
|
*
|
|
* `resume: false` declines the immediate wake; it does not exempt the message
|
|
* from the next promotion, which is what `delivery` governs. Widened to
|
|
* `unknown` like `prompt`'s, because the delivery literals are the host's.
|
|
*
|
|
* 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;
|
|
delivery?: unknown;
|
|
}) => 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 when nothing of ours is
|
|
* already pending ahead of it.
|
|
*
|
|
* 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";
|
|
|
|
/**
|
|
* How BOTH the session-URL notice and the feedback that follows it are admitted,
|
|
* so OpenCode promotes them into ONE model turn (#1515).
|
|
*
|
|
* OpenCode 2 admits every plugin message — `session.prompt` and
|
|
* `session.synthetic` alike — as a pending inbox row, and `resume: false` only
|
|
* declines to wake the session NOW; it never exempts the row from a later
|
|
* promotion. Promotion is not symmetric between the two delivery kinds
|
|
* (`SessionInbox.promote`, `packages/core/src/session/inbox.ts` @ v2.0.2):
|
|
*
|
|
* - pending steers are promoted as a BATCH ("publish(db, bus, sessionID,
|
|
* control === -1 ? steers : steers.slice(0, control))"), so several steer
|
|
* rows enter the same turn;
|
|
* - a queued row is promoted ONE AT A TIME (the `limit(1)` select), and only
|
|
* after the steers.
|
|
*
|
|
* A promoted synthetic becomes a user-role message (`to-llm-message.ts`) and
|
|
* the runner then runs a full model step for it, with no "was this real user
|
|
* input?" guard. So a queued notice sitting ahead of queued feedback is
|
|
* promoted alone and becomes its own model turn, with the reviewer's feedback
|
|
* stuck behind it — exactly what #1515 reports. Riding "steer" for both is what
|
|
* makes the notice and the feedback one batch, which is the same reason
|
|
* upstream's own transcript notices (shell results, `Session.shell`) leave the
|
|
* host default of "steer" in place instead of queueing.
|
|
*
|
|
* Only used when this client actually posted a notice; a review that posted
|
|
* none keeps `FEEDBACK_DELIVERY` and its late-arrival guarantee.
|
|
*/
|
|
const CO_PROMOTED_DELIVERY = "steer";
|
|
|
|
/**
|
|
* 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 `CO_PROMOTED_DELIVERY` ("steer"), which is also the
|
|
* host default. #1459 tried "queue" here; #1515 is what that produced, because
|
|
* a queued row is promoted alone while steers are promoted as a batch. See
|
|
* `CO_PROMOTED_DELIVERY` above for the promotion rules and the citations. The
|
|
* feedback that follows rides the same delivery, so the notice enters the model
|
|
* turn the reviewer actually asked for instead of starting one of its own.
|
|
*
|
|
* 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,
|
|
/**
|
|
* Called once the host has ACCEPTED a notice, so the feedback that follows can
|
|
* be admitted with the delivery that co-promotes with it. Never called for a
|
|
* rejected notice: nothing is then pending and the plain queue delivery is
|
|
* still the right one.
|
|
*/
|
|
onAdmitted?: () => void,
|
|
): ((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);
|
|
const admitted = await synthetic({
|
|
sessionID,
|
|
text: notice,
|
|
description: notice,
|
|
resume: false,
|
|
// #1515: the notice and the feedback must share one promotion.
|
|
delivery: CO_PROMOTED_DELIVERY,
|
|
});
|
|
onAdmitted?.();
|
|
return admitted;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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>();
|
|
// Whether a session-URL notice of ours is still waiting in this session's
|
|
// inbox. It is the only thing that can sit AHEAD of the reviewer's feedback,
|
|
// and the plugin session domain exposes no way to withdraw a pending row
|
|
// (`SessionDomain` is a fixed Pick in `packages/plugin/src/promise/session.ts`
|
|
// with no inbox member, even though the server itself routes
|
|
// `session.inbox.cancel`), so the feedback joins its promotion instead.
|
|
let noticePending = false;
|
|
const notifyUrl = createSessionUrlNotifier(input.ctx, input.sessionID, () => {
|
|
noticePending = true;
|
|
});
|
|
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.");
|
|
}
|
|
const delivered = await prompt({
|
|
sessionID,
|
|
text: joinTextParts(Array.isArray(body.parts) ? body.parts : []),
|
|
delivery: noticePending ? CO_PROMOTED_DELIVERY : FEEDBACK_DELIVERY,
|
|
});
|
|
// Admitted: the notice is no longer the row ahead of us, so any later
|
|
// delivery on this client is a plain late arrival again.
|
|
noticePending = false;
|
|
return delivered;
|
|
},
|
|
},
|
|
};
|
|
}
|