Files
backnotprop__plannotator/apps/opencode-plugin/index.ts
T
Michael Ramos d5b6bc702b fix: single submit_plan with text/path auto-detect for OpenCode (#333)
* fix: restore uniform submit_plan and add submit_plan_file for OpenCode

PR #318 replaced the original text-based submit_plan with a file-path-only
version and split prompt injection between plan/non-plan agents. This caused
regressions: the agent couldn't figure out file paths from non-plan agents,
and the 80+ line prompt with TodoWrite replacements was fragile against
OpenCode upstream changes.

This restores the original submit_plan(plan) that accepts markdown text
directly — the contract that worked uniformly across all agents — and keeps
the file-based workflow as submit_plan_file(path) for users who want
persistent plan files.

Key changes:
- Two tools: submit_plan (text) + submit_plan_file (path)
- Unified prompt for all primary agents (not just plan mode)
- Removed aggressive TodoWrite string replacements and system-reminder
- Kept adversarial stripping of OpenCode's STRICTLY FORBIDDEN rules
- Extracted shared server helpers to reduce duplication

Addresses #328

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: single submit_plan with auto-detect text/path for OpenCode

Collapses submit_plan + submit_plan_file back into one tool that
auto-detects whether the argument is plan text or a file path.

First submission: agent passes markdown text (simple, works from any agent).
On deny: response includes the history path where the plan was saved, so the
agent can Edit the file for targeted revisions and resubmit with the path.

Key changes:
- One tool: submit_plan(plan) accepts text or absolute .md file path
- Server surfaces historyPath through waitForDecision (already saved by
  saveToHistory, just not returned before)
- Deny response includes file path hint for Edit-based revision workflow
- Unified prompt for all primary agents (~25 lines, no TodoWrite warfare)
- Still strips OpenCode's STRICTLY FORBIDDEN rules and suppresses plan_exit

Addresses #328 and rcdailey's feedback on PR #333

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove historyPath leak, error on missing file paths, restore two-tier prompt

- Remove historyPath from deny flow — internal history directory should not
  be exposed to agents. Text submissions get text feedback; file submissions
  get file feedback. No crossover.
- Error when agent passes an absolute .md path that doesn't exist instead of
  silently treating it as plan text.
- Restore two-tier prompt: plan agent gets full planning instructions, other
  primary agents get a minimal reminder (matching pre-v0.13.0 behavior).
- Clean up stale prompt copy referencing historyPath-based revision flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore todowrite override, strengthen planning prompt, remove noisy logs

- Override todowrite description to defer to submit_plan during active planning
- Tool description now instructs agent to explore and ask questions before submitting
- Sequenced planning prompt: explore, ask, then write
- Remove success logs from integration saves (stderr was bleeding through)
- Append resubmit reminder to OpenCode deny feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:31:29 -07:00

467 lines
17 KiB
TypeScript

/**
* Plannotator Plugin for OpenCode
*
* Provides interactive browser-based plan review via a single tool:
* submit_plan(plan) — accepts either markdown text or a file path
*
* First submission: agent passes plan as text. On deny, the response includes
* the path where the plan was saved, enabling the agent to use Edit for targeted
* revisions and resubmit with the file path.
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH)
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
* PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for approval (default: 345600, set 0 to disable)
* PLANNOTATOR_ALLOW_SUBAGENTS - Set to "1" to allow subagents to see submit_plan
*
* @packageDocumentation
*/
import { type Plugin, tool } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "fs";
import path from "path";
import {
startPlannotatorServer,
handleServerReady,
} from "@plannotator/server";
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import {
handleReviewCommand,
handleAnnotateCommand,
handleAnnotateLastCommand,
type CommandDeps,
} from "./commands";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
import {
stripConflictingPlanModeRules,
} from "./plan-mode";
// @ts-ignore - Bun import attribute for text
import indexHtml from "./plannotator.html" with { type: "text" };
const htmlContent = indexHtml as unknown as string;
// @ts-ignore - Bun import attribute for text
import reviewHtml from "./review-editor.html" with { type: "text" };
const reviewHtmlContent = reviewHtml as unknown as string;
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
// ── Auto-detection ────────────────────────────────────────────────────────
/**
* Detect whether the submit_plan argument is a file path.
* Must be an absolute path, end in .md, and exist on disk.
* Anything that doesn't match is treated as plan text.
*/
function isFilePath(value: string): boolean {
return path.isAbsolute(value) && value.endsWith(".md") && existsSync(value);
}
/**
* Resolve the plan content from the submit_plan argument.
* Returns the markdown text and optionally the source file path.
*/
function resolvePlanContent(plan: string): { content: string; filePath?: string } {
if (isFilePath(plan)) {
const content = readFileSync(plan, "utf-8");
if (!content.trim()) {
throw new Error(`Plan file at ${plan} is empty. Write your plan content first, then call submit_plan.`);
}
return { content, filePath: plan };
}
// Catch typos: looks like a file path but doesn't exist
if (path.isAbsolute(plan) && plan.endsWith(".md")) {
throw new Error(`File not found: ${plan}. Check the path and try again.`);
}
return { content: plan };
}
// ── Planning prompt ───────────────────────────────────────────────────────
/**
* Unified planning prompt injected for all primary agents.
*
* Design principles:
* - Explain the WHY — the model is smart, give it context
* - Keep it lean — every line should pull its weight
* - Don't overfit — let the agent and user dictate the workflow
* - One tool, two modes — text for first submission, file path for revisions
*/
function getPlanningPrompt(): string {
return `## Plannotator — Plan Review
You have a plan submission tool called \`submit_plan\`. It opens an interactive review UI where the user can annotate, approve, or request changes.
**How to use it:**
- Pass your plan as markdown text — \`submit_plan(plan: "# My Plan\\n...")\`.
- Or pass an absolute file path to a .md file — \`submit_plan(plan: "/path/to/plan.md")\`.
The tool auto-detects whether you passed text or a file path. Both open the same review UI.
### Before you write a plan
Do not jump straight to writing a plan. First:
1. **Explore** — Read the relevant code, trace dependencies, and look at existing patterns. The depth should match the task.
2. **Ask questions** — If you need information only the user can provide (requirements, preferences, tradeoffs), ask using the \`question\` tool. Don't guess at ambiguous requirements.
Only write and submit a plan once you have sufficient context.
### What NOT to do
- Don't proceed with implementation until the plan is approved.
- Don't use \`plan_exit\` — use \`submit_plan\` instead.
- Don't end your turn without either submitting a plan or asking the user a question.`;
}
// ── Plugin ────────────────────────────────────────────────────────────────
export const PlannotatorPlugin: Plugin = async (ctx) => {
let cachedAgents: any[] | null = null;
async function getSharingEnabled(): Promise<boolean> {
try {
const response = await ctx.client.config.get({ query: { directory: ctx.directory } });
// @ts-ignore - share config may exist
const share = response?.data?.share;
if (share !== undefined) {
return share !== "disabled";
}
} catch {
// Config read failed, fall through to env var
}
return process.env.PLANNOTATOR_SHARE !== "disabled";
}
function getShareBaseUrl(): string | undefined {
return process.env.PLANNOTATOR_SHARE_URL || 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;
}
if (parsed === 0) return null;
return parsed;
}
function allowSubagents(): boolean {
const val = process.env.PLANNOTATOR_ALLOW_SUBAGENTS?.trim();
return val === "1" || val === "true";
}
return {
// Register submit_plan as primary-only tool (hidden from sub-agents by default)
config: async (opencodeConfig) => {
if (!allowSubagents()) {
const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [];
if (!existingPrimaryTools.includes("submit_plan")) {
opencodeConfig.experimental = {
...opencodeConfig.experimental,
primary_tools: [...existingPrimaryTools, "submit_plan"],
};
}
}
// Allow the plan agent to write .md files anywhere.
// OpenCode's built-in plan agent uses relative-path globs that break
// when worktree != cwd (non-git projects). Per-agent config merges
// last, so this only affects the plan agent.
opencodeConfig.agent ??= {};
opencodeConfig.agent.plan ??= {};
opencodeConfig.agent.plan.permission ??= {};
opencodeConfig.agent.plan.permission.edit = {
...opencodeConfig.agent.plan.permission.edit,
"*.md": "allow",
};
},
// Strip OpenCode's "STRICTLY FORBIDDEN" plan mode prompt from synthetic
// user messages. OpenCode injects these to prevent file edits in plan mode,
// but we need the agent to be able to write plan files.
"experimental.chat.messages.transform": async (input, output) => {
for (const message of output.messages) {
if (message.info.role !== "user") continue;
message.parts = message.parts.filter(
(part: any) => !(part.type === "text" && part.text?.includes("STRICTLY FORBIDDEN"))
);
}
},
// Suppress plan_exit — redirect to submit_plan
// Override todowrite — defer to submit_plan during planning
"tool.definition": async (input, output) => {
if (input.toolID === "plan_exit") {
output.description =
"Do not call this tool. Use submit_plan instead — it opens a visual review UI for plan approval.";
}
if (input.toolID === "todowrite") {
output.description =
"While actively planning with the user, use submit_plan instead. Only use todos once implementation begins or unless the user explicitly asks.";
}
},
// Inject planning instructions into system prompt
"experimental.chat.system.transform": async (input, output) => {
const systemText = output.system.join("\n");
if (systemText.toLowerCase().includes("title generator") || systemText.toLowerCase().includes("generate a title")) {
return;
}
let lastUserAgent: string | undefined;
try {
const messagesResponse = await ctx.client.session.messages({
// @ts-ignore - sessionID exists on input
path: { id: input.sessionID }
});
const messages = messagesResponse.data;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info.role === "user") {
// @ts-ignore - UserMessage has agent field
lastUserAgent = msg.info.agent;
break;
}
}
}
if (!lastUserAgent) return;
// Build agent doesn't need planning instructions
if (lastUserAgent === "build") return;
// Cache agents list (static per session)
if (!cachedAgents) {
const agentsResponse = await ctx.client.app.agents({
query: { directory: ctx.directory }
});
cachedAgents = agentsResponse.data ?? [];
}
const agent = cachedAgents.find((a: { name: string }) => a.name === lastUserAgent);
// Skip sub-agents
// @ts-ignore - Agent has mode field
if (agent?.mode === "subagent") return;
} catch {
return;
}
// Plan agent: strip conflicting OpenCode rules, inject full prompt
if (lastUserAgent === "plan") {
output.system = stripConflictingPlanModeRules(output.system);
output.system.push(getPlanningPrompt());
return;
}
// Other primary agents: minimal reminder about the tool
output.system.push(`## Plan Submission
When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your plan as markdown text, or pass an absolute file path to a .md file.
The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, revise based on their feedback and call submit_plan again.
Do NOT proceed with implementation until your plan is approved.`);
},
// Intercept plannotator-last before the agent sees the command
"command.execute.before": async (input, output) => {
if (input.command !== "plannotator-last") return;
output.parts = [];
const deps: CommandDeps = {
client: ctx.client,
htmlContent,
reviewHtmlContent,
getSharingEnabled,
getShareBaseUrl,
directory: ctx.directory,
};
const feedback = await handleAnnotateLastCommand(
{ properties: { sessionID: input.sessionID } },
deps
);
if (feedback) {
try {
await ctx.client.session.prompt({
path: { id: input.sessionID },
body: {
parts: [{
type: "text",
text: `# Message Annotations\n\n${feedback}\n\nPlease address the annotation feedback above.`,
}],
},
});
} catch {
// Session may not be available
}
}
},
// Listen for slash commands (review + annotate)
event: async ({ event }) => {
const isCommandEvent =
event.type === "command.executed" ||
event.type === "tui.command.execute";
if (!isCommandEvent) return;
// @ts-ignore - Event structure varies
const commandName = event.properties?.name || event.command || event.payload?.name;
const deps: CommandDeps = {
client: ctx.client,
htmlContent,
reviewHtmlContent,
getSharingEnabled,
getShareBaseUrl,
directory: ctx.directory,
};
if (commandName === "plannotator-review")
return handleReviewCommand(event, deps);
if (commandName === "plannotator-annotate")
return handleAnnotateCommand(event, deps);
},
tool: {
submit_plan: tool({
description:
"Planning tool used to submit a plan to the user for review. Before calling this tool you must conduct interactive and exploratory analysis in order to submit a quality plan. Ask questions. Explore the codebase for context if needed. Only call submit_plan once you have enough details to create a quality plan. Work with the user to get those details. Pass either markdown text or an absolute path to a .md file.",
args: {
plan: tool.schema
.string()
.describe("The plan — either markdown text or an absolute path to a .md file on disk."),
},
async execute(args, context) {
// Auto-detect: file path or plan text
let planContent: string;
let sourceFilePath: string | undefined;
try {
const resolved = resolvePlanContent(args.plan);
planContent = resolved.content;
sourceFilePath = resolved.filePath;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
if (!planContent.trim()) {
return "Error: Plan content is empty. Write your plan first, then call submit_plan.";
}
const sharingEnabled = await getSharingEnabled();
const server = await startPlannotatorServer({
plan: planContent,
origin: "opencode",
sharingEnabled,
shareBaseUrl: getShareBaseUrl(),
htmlContent,
opencodeClient: ctx.client,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
}
},
});
const timeoutSeconds = getPlanTimeoutSeconds();
const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000;
const result = timeoutMs === null
? await server.waitForDecision()
: await new Promise<Awaited<ReturnType<typeof server.waitForDecision>>>((resolve) => {
const timeoutId = setTimeout(
() =>
resolve({
approved: false,
feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
}),
timeoutMs
);
server.waitForDecision().then((r) => {
clearTimeout(timeoutId);
resolve(r);
});
});
await Bun.sleep(1500);
server.stop();
if (result.approved) {
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
const targetAgent = result.agentSwitch || 'build';
if (shouldSwitchAgent) {
try {
await ctx.client.tui.executeCommand({
body: { command: "agent_cycle" },
});
} catch {
// Silently fail
}
try {
await ctx.client.session.prompt({
path: { id: context.sessionID },
body: {
agent: targetAgent,
noReply: true,
parts: [{ type: "text", text: "Proceed with implementation" }],
},
});
} catch {
// Silently fail if session is busy
}
}
if (result.feedback) {
return `Plan approved with notes!
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}
## Implementation Notes
The user approved your plan but added the following notes to consider during implementation:
${result.feedback}
Proceed with implementation, incorporating these notes where applicable.`;
}
return `Plan approved!${result.savedPath ? ` Saved to: ${result.savedPath}` : ""}`;
} else {
return planDenyFeedback(result.feedback || "", "submit_plan", {
planFilePath: sourceFilePath,
}) + "\n\nAfter making your revisions, call `submit_plan` again to resubmit for review.";
}
},
}),
},
};
};
export default PlannotatorPlugin;