Files
backnotprop__plannotator/apps/opencode-plugin/server.ts
Michael Ramos 121082430e fix: QA-gate hardening for the v0.26.8 feature set (overlay perf, numbering, OpenCode 2 parity) (#1258)
* fix(opencode): consolidate V2 system parts into one composed prompt (#1114)

The OpenCode 2 adapter still shipped the pre-#1114 multi-part system
injection: replacePlanningSystemParts kept one part per source and the
generic reminder pushed a separate part, so Qwen3.x Jinja template
corruption persisted for OpenCode 2 users. Mirror the V1 entry exactly:
compose the stripped existing text plus additions into a single system
part via composeSystemPrompt, and compose the generic reminder into the
existing text instead of appending a second part.

Also adds the regression tests for the bug class flagged in #1114's
review: both helpers must read/compose the existing system text BEFORE
truncating the array (a reorder to 'system.length = 0' first drops the
host prompt and goes red here).

* perf(annotate): harden the raw-HTML overlay reconcile (dead-target backoff, cull, batching)

Bridge-script hardening for mutation-heavy pages and large annotation
sets, plus the lost click-to-select hover affordance:

- A: dead-target re-search now carries a wall-clock backoff (300ms
  doubling to a 5s cap, reset on success) ON TOP of the generation gate,
  plus a 2-searches-per-reconcile-pass budget with a scheduled follow-up
  pass for budget-skipped eligible targets. A page that mutates every
  frame advances domGeneration every frame, so the generation gate alone
  re-ran the whole-document TreeWalker sweep (and anchor re-resolution)
  per frame forever for permanently unresolvable targets.
- B1: early viewport cull (64px margin) for element and range targets:
  wholly offscreen targets skip targetStyleHidden / getComputedStyle /
  clipBoundsFor / client-rect collection entirely and just omit their
  markers, which is what the visible pipeline produced anyway.
- B2: read/write batching in renderAnnotationOverlay: highlight rects are
  queued during the read phase and flushed as one write phase, so the
  pass no longer forces a synchronous layout per record.
- B3: restoreAnnotation defers its render through the existing
  rAF-coalesced reconcile scheduler; restoring N annotations now renders
  once instead of N full passes (searches stay synchronous for the
  mark-applied reply). DOM tests flush the frame via the suite's
  standard macrotask flush.
- B4: zero-work observer gate: page mutations with no records, no
  pending draft, and pinpoint inactive still bump domGeneration but no
  longer schedule a reconcile frame.
- D: hover affordance for click-to-select: the rAF-throttled mousemove
  hit-tests the pointer against the CACHED rendered committed rects and
  toggles a brightness class on that annotation's rect divs inside the
  shadow root. No page-DOM writes, rects stay pointer-transparent, and
  shadow-root writes are unobserved so there is no reconcile loop.
- G: while a text drag is in progress in drag mode, placed markers yield
  pointer input (data-pn-hittest) so the 25px bubble cannot capture a
  selection drag; armed only by a >4px primary-button move from a
  non-overlay mousedown, so marker clicks and click-to-select paths are
  untouched. withMarkersYielded now restores (not clears) the attribute.

New regression tests for A, B1, B3, B4, D; A/B1/B3 mutation-verified
(fix reverted, test observed failing, fix restored).

* fix(annotate): make on-page marker numbers match exportAnnotations numbering

The HtmlViewer sync excluded GLOBAL_COMMENT annotations before numbering
while exportAnnotations numbers '## N.' sections across the FULL list
including globals — so an on-page 'Comment 2' could be '## 3.' in the
feedback the agent reads. The sync now derives each marker's number from
its position in the full createdA-sorted list (globals occupy a number
but ship no entry, leaving the correct gaps on-page). Export format is
unchanged.

New buildSyncNumbering helper + tests asserting a mixed list yields
identical numbers between the sync payload and exportAnnotations output
(mutation-verified against the pre-fix ordering).

* chore: sync stale workspace versions in bun.lock (0.26.1 -> 0.26.7)

* docs: document raw-HTML overlay model, multi-target types, and known limitations

- Data Types: add htmlAdditionalTargets to the Annotation listing plus
  the HtmlElementAnchor (including the optional normalized point used by
  placed markers) and HtmlAnnotationTarget shapes.
- Annotation System: describe the post-#1257 raw-HTML surface (placed
  comment markers + overlay-projected highlights, no inline mark
  mutation; durable anchors persisted, disposable markers projected) and
  the print-parity limitation.
- URL Sharing: note that share links intentionally drop HTML element
  anchors and additional targets (restore is text-search based, per
  sharing.multiTarget.test.ts).

* test: fix Range.getClientRects stub typing in the B1 cull test

* fix(annotate): hover-race teardown and unbounded one-shot dead-search passes

Polish round on the overlay hardening:

- Hover race (1): switching into pinpoint mode (or opening a draft) now
  tears hover down fully via clearHoverHighlight() — cancels the pending
  rAF hit test and clears the tracked position and id — and the rAF
  callback itself refuses to paint outside drag mode / with an open
  draft. Previously the pending callback re-applied the class after the
  mode switch and every flushQueuedHighlights re-painted it from the
  stale hoverHighlightId, leaving a permanent phantom hover.
- One-shot budgets (3): beginDeadSearchPass takes a per-pass budget.
  Reconcile passes keep 2 (they repeat, skipped targets get follow-up
  frames); print and scroll-to are user-initiated one-shots with no
  follow-up and now run unbounded (backoff and generation gates still
  apply), so printing with 3+ dead-but-recoverable targets no longer
  silently prints fewer highlights.

Both changes carry new regression tests, mutation-verified (fix
reverted, test observed failing, fix restored).

* fix(annotate): number markers by array position and cap entries after dropping globals

The createdA sort made the export-match invariant false with external
annotations: exportAnnotations' sort keys tie for every raw-HTML
annotation (blockId '', startOffset 0), so its stable sort numbers the
combined [...local, ...external] list in ARRAY order — and external
annotations arrive appended with server-stamped createdA values that can
interleave with local timestamps. buildSyncNumbering now numbers by
array position of the input (verified to be the same combined list both
consumers receive from packages/editor/App.tsx allAnnotations; the
viewerAnnotations diffContext filter is order-preserving and vacuous on
the raw-HTML surface).

Also reorders the cap: number the full list, drop globals, THEN slice
512 entries — globals no longer waste sync capacity and a non-global the
export numbers past position 512 still syncs while slots remain. Numbers
may now exceed 512 (array positions); the bridge's own bound (100000)
accepts them and its 512-entry cap still agrees with the sender.

Tests updated: interleaved-external agreement with exportAnnotations
(mutation-verified against the createdA sort) and slice-after-filter
capacity.

* docs(opencode): note the accepted cache-hint flattening trade-off in V2 consolidation
2026-08-10 15:27:04 -07:00

421 lines
16 KiB
TypeScript

import type { Plugin } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { loadConfig, resolveSharingEnabled } from "@plannotator/shared/config";
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
import { composeSystemPrompt, stripConflictingPlanModeRules } from "./plan-mode";
import {
isPlanningAgent,
normalizeWorkflowOptions,
shouldInjectFullPlanningPrompt,
shouldInjectGenericPlanReminder,
shouldModifyPrompts,
shouldRegisterSubmitPlan,
type PlannotatorOpenCodeOptions,
type RuntimeMode,
} from "./workflow";
import {
runCliPlanReview,
type OpenCodeBridgeAgent,
type OpenCodeBridgeContext,
type OpenCodePlanReviewResult,
} from "./cli-bridge";
import { resolveTargetAgent } from "./agent-switch";
import { executeSubmitPlan } from "./submit-plan-executor";
import type { PlanEdit } from "./plan-edits";
import { getPlanningPrompt } from "./planning-prompt";
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
let planHtml: string | undefined;
type V2Client = {
app: {
agents: () => Promise<{ data: OpenCodeBridgeAgent[] }>;
log: (entry: { level: "info" | "error"; message: string }) => void;
};
};
type EmbeddedRuntimeModule = {
runEmbeddedPlanReview: (input: {
client: V2Client;
planContent: string;
sharingEnabled: boolean;
shareBaseUrl?: string;
pasteApiUrl?: string;
htmlContent: string;
timeoutSeconds: number | null;
abortSignal?: AbortSignal;
logReady: (url: string, isRemote: boolean, port: number) => void;
}) => Promise<OpenCodePlanReviewResult>;
};
// `Plugin.define` is an identity function in @opencode-ai/plugin; keeping the import
// type-only avoids shipping a runtime dependency on an exact prerelease nightly.
const serverPlugin = {
id: "plannotator",
setup: async (ctx) => {
const workflowOptions = normalizeWorkflowOptions(ctx.options as PlannotatorOpenCodeOptions);
let cachedAgents: OpenCodeBridgeAgent[] | undefined;
const getAgents = async (): Promise<OpenCodeBridgeAgent[]> => {
if (cachedAgents) return cachedAgents;
try {
const response = await ctx.agent.list();
cachedAgents = response.data.map((agent) => ({
name: agent.id,
description: agent.description,
mode: agent.mode,
hidden: agent.hidden,
}));
} catch {
cachedAgents = [];
}
return cachedAgents;
};
if (shouldModifyPrompts(workflowOptions)) {
await ctx.session.hook("context", async (event) => {
if (
workflowOptions.workflow === "plan-agent"
&& !isPlanningAgent(event.agent, workflowOptions)
) {
delete event.tools.submit_plan;
return;
}
const currentAgent = workflowOptions.workflow === "all-agents"
? (await getAgents()).find((candidate) => candidate.name === event.agent)
: undefined;
if (!allowSubagents() && currentAgent?.mode === "subagent") {
delete event.tools.submit_plan;
return;
}
if (event.tools.plan_exit) {
event.tools.plan_exit.description =
"Do not call this tool. Use submit_plan instead - it opens a visual review UI for plan approval.";
}
if (event.tools.todowrite) {
event.tools.todowrite.description =
"While actively planning with the user, use submit_plan instead. Only use todos once implementation begins or unless the user explicitly asks.";
}
replaceStrictPlanReminder(event.messages);
const systemText = event.system.map((part) => part.text).join("\n").toLowerCase();
if (systemText.includes("title generator") || systemText.includes("generate a title")) return;
if (shouldInjectFullPlanningPrompt(event.agent, workflowOptions)) {
const additions = [getPlanningPrompt()];
const hook = readImprovementHook("enterplanmode-improve");
const improveContext = composeImproveContext({
pfmEnabled: loadConfig().pfmReminder === true,
improvementHookContent: hook?.content ?? null,
});
if (improveContext) additions.push(improveContext);
replacePlanningSystemParts(
event.system,
additions,
);
return;
}
if (!shouldInjectGenericPlanReminder(
event.agent,
currentAgent?.mode === "subagent",
workflowOptions,
)) return;
pushComposedSystemReminder(event.system, getGenericPlanReminder());
});
}
if (!shouldRegisterSubmitPlan(workflowOptions)) return;
await ctx.tool.transform((tools) => {
tools.add({
name: "submit_plan",
description:
"Submit a plan for user review via line-range edits. First call: pass a single edit with start=1 and your full plan as content (omit end). Subsequent calls after denial: pass targeted edits using the line numbers from the previous response. The tool manages a backing file; you never touch the file directly.",
input: {
type: "object",
properties: {
edits: {
type: "array",
items: {
type: "object",
properties: {
start: {
type: "number",
description: "1-indexed start line (inclusive)",
},
end: {
type: "number",
description: "1-indexed end line (inclusive). Omit to replace from start through end of file.",
},
content: {
type: "string",
description: "Replacement content. Empty string deletes the line range.",
},
},
required: ["start", "content"],
additionalProperties: false,
},
description: "Array of line-range edits to apply to the plan.",
},
},
required: ["edits"],
additionalProperties: false,
},
options: { codemode: false },
execute: async (input, toolContext) => {
const session = await ctx.session.get({ sessionID: toolContext.sessionID });
const directory = session.location.directory;
const bridge = await getBridgeContext(getAgents);
const client = createV2Client(getAgents);
const result = await executeSubmitPlan({
edits: getPlanEdits(input),
invokingAgent: toolContext.agent,
sessionId: toolContext.sessionID,
directory,
workflowOptions,
}, {
reviewPlan: async ({ planContent }) => await runPlanReview({
client,
runtime: workflowOptions.runtime,
planContent,
sharingEnabled: bridge.sharingEnabled ?? true,
shareBaseUrl: bridge.shareBaseUrl,
pasteApiUrl: bridge.pasteApiUrl,
timeoutSeconds: getPlanTimeoutSeconds(),
directory,
bridge,
}),
resolveTargetAgent: async ({ requestedAgent }) => {
const targetAgent = resolveTargetAgent(requestedAgent);
if (!targetAgent) return undefined;
const available = (await getAgents()).some((agent) => agent.name === targetAgent);
if (!available) {
console.error(`[Plannotator] Configured OpenCode agent "${targetAgent}" is not available; approving the plan without switching agents.`);
return undefined;
}
// The current OpenCode 2 API exposes no session-agent switch operation to plugins.
console.error("[Plannotator] OpenCode 2 does not currently expose agent switching to plugins; approving the plan without switching agents.");
return undefined;
},
sendApprovalHandoff: async () => {},
});
return { content: result };
},
});
});
},
} satisfies Plugin.Plugin;
function getPlanEdits(input: unknown): PlanEdit[] | undefined {
if (!input || typeof input !== "object") return undefined;
const edits = Reflect.get(input, "edits");
return Array.isArray(edits) ? edits as PlanEdit[] : undefined;
}
function getPlanTimeoutSeconds(): number | null {
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
console.error(`[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`);
return DEFAULT_PLAN_TIMEOUT_SECONDS;
}
return parsed === 0 ? null : parsed;
}
function createV2Client(
getAgents: () => Promise<OpenCodeBridgeAgent[]>,
): V2Client {
const loggedUrls = new Set<string>();
return {
app: {
agents: async () => ({ data: await getAgents() }),
log: ({ message }) => {
const url = /https?:\/\/\S+/.exec(message)?.[0];
if (url && loggedUrls.has(url)) return;
if (url) loggedUrls.add(url);
console.error(message);
},
},
};
}
function allowSubagents(): boolean {
const value = process.env.PLANNOTATOR_ALLOW_SUBAGENTS?.trim();
return value === "1" || value === "true";
}
async function getBridgeContext(
getAgents: () => Promise<OpenCodeBridgeAgent[]>,
): Promise<OpenCodeBridgeContext> {
return {
sharingEnabled: resolveSharingEnabled(loadConfig()),
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
agents: await getAgents(),
};
}
function hasEmbeddedRuntime(): boolean {
return typeof (globalThis as typeof globalThis & { Bun?: { serve?: unknown } }).Bun?.serve === "function";
}
async function importEmbeddedRuntime(): Promise<EmbeddedRuntimeModule> {
const builtPath = path.join(moduleDir, "embedded.js");
if (existsSync(builtPath)) {
return await import(pathToFileURL(builtPath).href) as EmbeddedRuntimeModule;
}
const sourceSpecifier = "./embedded";
return await import(sourceSpecifier) as EmbeddedRuntimeModule;
}
function getPlanHtml(): string {
if (planHtml) return planHtml;
const candidates = [
path.join(moduleDir, "plannotator.html"),
path.join(moduleDir, "..", "plannotator.html"),
];
const htmlPath = candidates.find((candidate) => existsSync(candidate));
if (!htmlPath) throw new Error("Could not find bundled HTML asset: plannotator.html");
planHtml = readFileSync(htmlPath, "utf-8");
return planHtml;
}
async function runPlanReview(input: {
client: V2Client;
runtime: RuntimeMode;
planContent: string;
sharingEnabled: boolean;
shareBaseUrl?: string;
pasteApiUrl?: string;
timeoutSeconds: number | null;
abortSignal?: AbortSignal;
directory: string;
bridge: OpenCodeBridgeContext;
}): Promise<OpenCodePlanReviewResult> {
if (input.runtime === "embedded" && !hasEmbeddedRuntime()) {
throw new Error('runtime "embedded" requires a Bun-hosted OpenCode plugin runtime. Use runtime "auto" or "cli" with this OpenCode host.');
}
if (input.runtime !== "cli" && hasEmbeddedRuntime()) {
try {
const embedded = await importEmbeddedRuntime();
return await embedded.runEmbeddedPlanReview({
client: input.client,
planContent: input.planContent,
sharingEnabled: input.sharingEnabled,
shareBaseUrl: input.shareBaseUrl,
pasteApiUrl: input.pasteApiUrl,
htmlContent: getPlanHtml(),
timeoutSeconds: input.timeoutSeconds,
abortSignal: input.abortSignal,
// Intentionally empty. OpenCode 2's server-plugin context exposes no log or
// tui domain, and the V2 client's app.log falls through to console.error,
// which is the same stderr stream handleServerReady already prints to.
// Wiring this up would duplicate the session URL in remote mode and add a
// stray line locally. V1 does target client.app.log and client.tui.showToast,
// which are HTTP surfaces separate from stderr, so V1 never repeats itself.
// A real toast here needs an upstream OpenCode API that does not exist yet.
logReady: () => {},
});
} catch (error) {
if (input.runtime === "embedded") throw error;
console.error(`[Plannotator] Embedded runtime unavailable; falling back to CLI: ${error instanceof Error ? error.message : String(error)}`);
}
}
return await runCliPlanReview({
client: input.client,
planContent: input.planContent,
cwd: input.directory,
timeoutSeconds: input.timeoutSeconds,
abortSignal: input.abortSignal,
bridge: input.bridge,
});
}
type SystemPart = { type: "text"; text: string; [key: string]: unknown };
/**
* Replace the system array with ONE composed text part (#1114): multiple
* system parts corrupt Qwen3.x Jinja chat templates, which render each part
* as its own system message. Mirrors the V1 entry (index.ts) exactly —
* stripped existing text first, then the additions, joined by blank lines.
*
* Order matters: the existing texts are read and composed BEFORE the array is
* truncated. Reordering to `system.length = 0` first silently drops the
* host's entire system prompt (the bug class flagged in #1114's review).
*
* Accepted trade-off: consolidation flattens per-part metadata (e.g.
* third-party cache hints) — template integrity beats part-level caching.
*/
export function replacePlanningSystemParts(
system: SystemPart[],
additions: string[],
): void {
const stripped = stripConflictingPlanModeRules(system.map((part) => part.text));
const composed = composeSystemPrompt([], [...stripped, ...additions.filter(Boolean)]);
system.length = 0;
system.push(...composed.map((text) => ({ type: "text" as const, text })));
}
/**
* Append a reminder by composing it into a single system part (#1114) instead
* of pushing a separate part — same Jinja-template rationale as above, and
* the same compose-before-truncate ordering requirement.
*/
export function pushComposedSystemReminder(
system: SystemPart[],
reminder: string,
): void {
const composed = composeSystemPrompt(system.map((part) => part.text), [reminder]);
system.length = 0;
system.push(...composed.map((text) => ({ type: "text" as const, text })));
}
function replaceStrictPlanReminder(messages: unknown[]): void {
for (const message of messages) {
if (!message || typeof message !== "object" || Reflect.get(message, "role") !== "user") continue;
const content = Reflect.get(message, "content");
if (!Array.isArray(content)) continue;
for (const part of content) {
if (!part || typeof part !== "object" || Reflect.get(part, "type") !== "text") continue;
const text = Reflect.get(part, "text");
if (typeof text !== "string" || !text.includes("STRICTLY FORBIDDEN")) continue;
Reflect.set(part, "text", `<system-reminder>
# Plan Mode - System Reminder
CRITICAL: Plan mode ACTIVE. You are in a PLANNING phase. The ONLY file modifications
allowed are writing or editing markdown files (.md) - plans, specs, documentation, etc.
All other file edits, code modifications, and system changes are STRICTLY FORBIDDEN.
Do NOT use shell commands to manipulate non-markdown files. Commands may ONLY read/inspect.
Use submit_plan to submit the completed plan for user review. Do not proceed with
implementation until the plan is approved.
</system-reminder>`);
}
}
}
function getGenericPlanReminder(): string {
return `## Plan Submission
When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your full plan as a single edit: \`{ "edits": [{ "start": 1, "content": "..." }] }\`.
The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, the response includes your plan with line numbers; use targeted edits to revise specific sections.
Do NOT proceed with implementation until your plan is approved.`;
}
export default serverPlugin;