diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 88e56dfd8..74f700fa6 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code, Droid, Codex, Gemini CLI, and Copilot CLI * - * Supports nine modes: + * Supports twelve modes: * * 1. Plan Review (default, no args): * - Spawned by Claude/Gemini/Codex hook entrypoints @@ -41,7 +41,18 @@ * - Opens the bundled question or facts acceptance UI * - Outputs structured JSON for setup-goal workflows * - * 9. Improve Context (`plannotator improve-context`): + * 9. OpenCode Plan (`plannotator opencode-plan`): + * - Internal bridge mode used by the OpenCode plugin CLI fallback + * - Reads `{ plan, timeoutSeconds, sharingEnabled, agents }` from stdin + * - Outputs structured JSON for the plugin + * + * 10. OpenCode Review (`plannotator opencode-review`): + * - Internal structured review bridge used by the OpenCode plugin CLI fallback + * + * 11. OpenCode Last (`plannotator opencode-annotate-last`): + * - Internal structured last-message annotation bridge for OpenCode + * + * 12. Improve Context (`plannotator improve-context`): * - Spawned by PreToolUse hook on EnterPlanMode * - Reads improvement hook file from ~/.plannotator/hooks/ * - Returns additionalContext or silently passes through @@ -280,6 +291,98 @@ const detectedOrigin: Origin = process.env.GEMINI_CLI ? "gemini-cli" : "claude-code"; +type OpenCodeBridgeAgent = { + name: string; + description?: string; + mode: string; + hidden?: boolean; +}; + +type OpenCodeBridgeInput = { + sharingEnabled?: unknown; + shareBaseUrl?: unknown; + pasteApiUrl?: unknown; + agents?: unknown; +}; + +function parseOpenCodeBridgeInput( + mode: string, + inputJson: string, +): T & OpenCodeBridgeInput { + try { + return JSON.parse(inputJson) as T & OpenCodeBridgeInput; + } catch (error) { + console.error(`Failed to parse ${mode} input: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +function getBridgeSharingEnabled(input: OpenCodeBridgeInput): boolean { + return typeof input.sharingEnabled === "boolean" ? input.sharingEnabled : sharingEnabled; +} + +function getBridgeShareBaseUrl(input: OpenCodeBridgeInput): string | undefined { + return typeof input.shareBaseUrl === "string" && input.shareBaseUrl ? input.shareBaseUrl : shareBaseUrl; +} + +function getBridgePasteApiUrl(input: OpenCodeBridgeInput): string | undefined { + return typeof input.pasteApiUrl === "string" && input.pasteApiUrl ? input.pasteApiUrl : pasteApiUrl; +} + +function normalizeOpenCodeBridgeAgents(value: unknown): OpenCodeBridgeAgent[] | undefined { + if (!Array.isArray(value)) return undefined; + + const agents = value + .map((agent): OpenCodeBridgeAgent | null => { + if (!agent || typeof agent !== "object") return null; + const record = agent as Record; + if (typeof record.name !== "string" || !record.name) return null; + return { + name: record.name, + ...(typeof record.description === "string" && { description: record.description }), + mode: typeof record.mode === "string" ? record.mode : "primary", + ...(typeof record.hidden === "boolean" && { hidden: record.hidden }), + }; + }) + .filter((agent): agent is OpenCodeBridgeAgent => agent !== null); + + return agents.length > 0 ? agents : undefined; +} + +function makeOpenCodeBridgeClient(agents: unknown) { + const data = normalizeOpenCodeBridgeAgents(agents); + if (!data) return undefined; + + return { + app: { + agents: async () => ({ data }), + }, + }; +} + +function emitOpenCodeAnnotateOutcome(result: { + feedback: string; + exit?: boolean; + approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; +}): void { + if (result.approved) { + console.log(JSON.stringify({ decision: "approved" })); + return; + } + if (result.exit) { + console.log(JSON.stringify({ decision: "dismissed" })); + return; + } + console.log(JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + ...(result.selectedMessageId && { selectedMessageId: result.selectedMessageId }), + ...(result.feedbackScope && { feedbackScope: result.feedbackScope }), + })); +} + if (args[0] === "sessions") { // ============================================ // SESSION DISCOVERY MODE @@ -1061,6 +1164,284 @@ if (args[0] === "sessions") { server.stop(); process.exit(0); +} else if (args[0] === "opencode-plan") { + // ============================================ + // OPENCODE PLUGIN PLAN REVIEW MODE + // ============================================ + // + // Internal CLI bridge used when the OpenCode plugin is running in a host + // that cannot import Bun-only server modules directly. + + const inputJson = await Bun.stdin.text(); + const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown }>( + "opencode-plan", + inputJson, + ); + + const planContent = typeof input.plan === "string" ? input.plan : ""; + if (!planContent.trim()) { + console.error("No plan content in opencode-plan input"); + process.exit(1); + } + + const timeoutSeconds = input.timeoutSeconds === null + ? null + : typeof input.timeoutSeconds === "number" && Number.isFinite(input.timeoutSeconds) && input.timeoutSeconds > 0 + ? input.timeoutSeconds + : null; + + const planProject = (await detectProjectName()) ?? "_unknown"; + const bridgeSharingEnabled = getBridgeSharingEnabled(input); + const bridgeShareBaseUrl = getBridgeShareBaseUrl(input); + const bridgePasteApiUrl = getBridgePasteApiUrl(input); + const server = await startPlannotatorServer({ + plan: planContent, + origin: "opencode", + sharingEnabled: bridgeSharingEnabled, + shareBaseUrl: bridgeShareBaseUrl, + pasteApiUrl: bridgePasteApiUrl, + htmlContent: planHtmlContent, + opencodeClient: makeOpenCodeBridgeClient(input.agents), + onReady: async (url, isRemote, port) => { + await handleServerReady(url, isRemote, port); + + if (isRemote && bridgeSharingEnabled) { + await writeRemoteShareLink(planContent, bridgeShareBaseUrl, "review the plan", "plan only").catch(() => {}); + } + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "plan", + project: planProject, + startedAt: new Date().toISOString(), + label: `plan-${planProject}`, + }); + + const result = timeoutSeconds === null + ? await server.waitForDecision() + : await new Promise>>((resolve) => { + const timeoutId = setTimeout( + () => + resolve({ + approved: false, + feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`, + }), + timeoutSeconds * 1000, + ); + + server.waitForDecision().then((decision) => { + clearTimeout(timeoutId); + resolve(decision); + }); + }); + + await Bun.sleep(1500); + server.stop(); + + console.log(JSON.stringify({ + approved: result.approved, + ...(result.feedback && { feedback: result.feedback }), + ...(result.savedPath && { savedPath: result.savedPath }), + ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), + })); + process.exit(0); + +} else if (args[0] === "opencode-review") { + // ============================================ + // OPENCODE PLUGIN CODE REVIEW MODE + // ============================================ + // + // Internal structured CLI bridge used when the OpenCode plugin is running + // in a host that cannot import Bun-only server modules directly. + + const inputJson = await Bun.stdin.text(); + const input = parseOpenCodeBridgeInput<{ arguments?: unknown }>( + "opencode-review", + inputJson, + ); + const reviewArgs = parseReviewArgs(typeof input.arguments === "string" ? input.arguments : ""); + const urlArg = reviewArgs.prUrl; + const isPRMode = urlArg !== undefined; + + let rawPatch: string; + let gitRef: string; + let diffError: string | undefined; + let userDiffType: DiffType | undefined; + let gitContext: Awaited>["gitContext"] | undefined; + let prMetadata: Awaited>["metadata"] | undefined; + + if (isPRMode) { + const prRef = parsePRUrl(urlArg); + if (!prRef) { + console.error(`Invalid PR/MR URL: ${urlArg}`); + process.exit(1); + } + + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + + try { + await checkPRAuth(prRef); + } catch (err) { + const cliName = getCliName(prRef); + console.error(err instanceof Error ? err.message : `${cliName} auth check failed`); + process.exit(1); + } + + try { + const pr = await fetchPR(prRef); + rawPatch = pr.rawPatch; + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; + prMetadata = pr.metadata; + } catch (err) { + console.error(err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`); + process.exit(1); + } + } else { + console.error("Opening code review UI..."); + + const config = loadConfig(); + const diffResult = await prepareLocalReviewDiff({ + cwd: process.env.PLANNOTATOR_CWD || process.cwd(), + vcsType: reviewArgs.vcsType, + configuredDiffType: resolveDefaultDiffType(config), + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); + gitContext = diffResult.gitContext; + userDiffType = diffResult.diffType; + rawPatch = diffResult.rawPatch; + gitRef = diffResult.gitRef; + diffError = diffResult.error; + } + + const bridgeSharingEnabled = getBridgeSharingEnabled(input); + const bridgeShareBaseUrl = getBridgeShareBaseUrl(input); + const reviewProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startReviewServer({ + rawPatch, + gitRef, + error: diffError, + origin: "opencode", + diffType: isPRMode ? undefined : userDiffType, + gitContext, + prMetadata, + sharingEnabled: bridgeSharingEnabled, + shareBaseUrl: bridgeShareBaseUrl, + htmlContent: reviewHtmlContent, + opencodeClient: makeOpenCodeBridgeClient(input.agents), + onReady: (url, isRemote, port) => { + handleReviewServerReady(url, isRemote, port); + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "review", + project: reviewProject, + startedAt: new Date().toISOString(), + label: isPRMode && prMetadata + ? `${getMRLabel(prMetadata).toLowerCase()}-review-${getDisplayRepo(prMetadata)}${getMRNumberLabel(prMetadata)}` + : `review-${reviewProject}`, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + console.log(JSON.stringify({ + decision: result.exit + ? "dismissed" + : result.approved + ? "approved" + : "annotated", + approved: result.approved, + isPRMode, + ...(result.feedback && { feedback: result.feedback }), + ...(result.agentSwitch && { agentSwitch: result.agentSwitch }), + })); + process.exit(0); + +} else if (args[0] === "opencode-annotate-last") { + // ============================================ + // OPENCODE PLUGIN ANNOTATE LAST MESSAGE MODE + // ============================================ + + const inputJson = await Bun.stdin.text(); + const input = parseOpenCodeBridgeInput<{ + gate?: unknown; + recentMessages?: unknown; + }>("opencode-annotate-last", inputJson); + + const recentMessages = Array.isArray(input.recentMessages) + ? input.recentMessages + .map((message): { messageId: string; text: string; timestamp?: string } | null => { + if (!message || typeof message !== "object") return null; + const record = message as Record; + if (typeof record.text !== "string" || !record.text.trim()) return null; + return { + messageId: typeof record.messageId === "string" && record.messageId + ? record.messageId + : crypto.randomUUID(), + text: record.text, + ...(typeof record.timestamp === "string" && { timestamp: record.timestamp }), + }; + }) + .filter((message): message is { messageId: string; text: string; timestamp?: string } => message !== null) + : []; + + const lastMessage = recentMessages[0] ?? null; + if (!lastMessage) { + console.error("No assistant message found in opencode-annotate-last input."); + process.exit(1); + } + + console.error("Opening annotation UI for last message..."); + + const bridgeSharingEnabled = getBridgeSharingEnabled(input); + const bridgeShareBaseUrl = getBridgeShareBaseUrl(input); + const bridgePasteApiUrl = getBridgePasteApiUrl(input); + const annotateProject = (await detectProjectName()) ?? "_unknown"; + const pickerMessages = recentMessages.length > 1 ? recentMessages : undefined; + + const server = await startAnnotateServer({ + markdown: lastMessage.text, + filePath: "last-message", + origin: "opencode", + mode: "annotate-last", + recentMessages: pickerMessages, + sharingEnabled: bridgeSharingEnabled, + shareBaseUrl: bridgeShareBaseUrl, + pasteApiUrl: bridgePasteApiUrl, + gate: input.gate === true, + htmlContent: planHtmlContent, + onReady: (url, isRemote, port) => { + handleAnnotateServerReady(url, isRemote, port); + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "annotate", + project: annotateProject, + startedAt: new Date().toISOString(), + label: "annotate-last", + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + emitOpenCodeAnnotateOutcome(result); + process.exit(0); + } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 23dd048af..a4b7c288a 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -59,6 +59,19 @@ Default config: } ``` +Runtime selection is automatic. In Bun-hosted OpenCode, Plannotator uses the embedded server bundled with the plugin. In Node-hosted or wrapped OpenCode environments, the plugin falls back to the installed `plannotator` CLI and sends the result back through OpenCode. You can force the fallback while debugging: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["@plannotator/opencode@latest", { + "runtime": "cli" + }] + ] +} +``` + If you use other OpenCode plugins, keep everything in one `plugin` array and attach Plannotator's options directly to the Plannotator entry: ```json @@ -144,6 +157,7 @@ Register the tool but manage prompts and permissions yourself: | `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. Default: `https://plannotator-paste.plannotator.workers.dev`. | | `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. | +| `PLANNOTATOR_BIN` | Override the CLI path used by the OpenCode plugin's CLI runtime fallback. Default: `plannotator` on `PATH`. | ## Devcontainer / Docker diff --git a/apps/opencode-plugin/cli-bridge.test.ts b/apps/opencode-plugin/cli-bridge.test.ts new file mode 100644 index 000000000..0f6d7d1c9 --- /dev/null +++ b/apps/opencode-plugin/cli-bridge.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + buildAnnotateCliArgs, + buildCliBridgeEnv, + buildCliSpawnConfig, + buildReviewPromptFromBridgeOutcome, + formatUserFacingCliStderrLine, + getRecentAssistantMessages, +} from "./cli-bridge"; + +describe("OpenCode CLI bridge helpers", () => { + test("maps OpenCode sharing context into child CLI env", () => { + expect(buildCliBridgeEnv({ + sharingEnabled: false, + shareBaseUrl: "https://share.example.test", + pasteApiUrl: "https://paste.example.test", + })).toEqual({ + PLANNOTATOR_SHARE: "disabled", + PLANNOTATOR_SHARE_URL: "https://share.example.test", + PLANNOTATOR_PASTE_URL: "https://paste.example.test", + }); + + expect(buildCliBridgeEnv({ sharingEnabled: true })).toEqual({ + PLANNOTATOR_SHARE: "enabled", + }); + }); + + test("builds annotate CLI args without folding flags into the path", () => { + const args = buildAnnotateCliArgs({ + filePath: "https://example.com/docs", + rawFilePath: "https://example.com/docs", + gate: true, + json: false, + hook: false, + renderHtml: true, + noJina: true, + }); + + expect(args).toEqual([ + "annotate", + "https://example.com/docs", + "--json", + "--gate", + "--render-html", + "--no-jina", + ]); + }); + + test("surfaces remote share-link stderr lines and ignores noisy stderr", () => { + expect(formatUserFacingCliStderrLine(" Open this link on your local machine to review the plan:")).toBe( + "Open this link on your local machine to review the plan:", + ); + expect(formatUserFacingCliStderrLine(" https://share.plannotator.ai/#abc")).toBe( + "https://share.plannotator.ai/#abc", + ); + expect(formatUserFacingCliStderrLine(" (1.2 KB - plan only, annotations added in browser)")).toBe( + "(1.2 KB - plan only, annotations added in browser)", + ); + expect(formatUserFacingCliStderrLine("Fetching: https://example.com")).toBeUndefined(); + }); + + test("resolves Windows CLI commands to an executable without shell mode", () => { + const dir = mkdtempSync(path.join(tmpdir(), "plannotator-cli-")); + try { + const exe = path.join(dir, "plannotator.exe"); + writeFileSync(exe, ""); + + const config = buildCliSpawnConfig( + "plannotator", + ["annotate", "my notes.md", "--json"], + "win32", + { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + ); + + expect(config).toEqual({ + command: exe, + args: ["annotate", "my notes.md", "--json"], + shell: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("collects recent assistant messages newest-first with ids and timestamps", async () => { + const client = { + session: { + messages: mock(async () => ({ + data: [ + { + info: { role: "assistant", id: "old", time: { created: 1_700_000_000_000 } }, + parts: [{ type: "text", text: "Old" }], + }, + { + info: { role: "user", id: "user" }, + parts: [{ type: "text", text: "Ignore me" }], + }, + { + info: { role: "assistant", id: "latest", time: { created: 1_700_000_001_000 } }, + parts: [{ type: "text", text: "Latest" }], + }, + ], + })), + }, + }; + + const messages = await getRecentAssistantMessages(client, "session-1"); + + expect(messages).toEqual([ + { + messageId: "latest", + text: "Latest", + timestamp: new Date(1_700_000_001_000).toISOString(), + }, + { + messageId: "old", + text: "Old", + timestamp: new Date(1_700_000_000_000).toISOString(), + }, + ]); + }); + + test("formats structured review outcomes for OpenCode prompt injection", () => { + expect(buildReviewPromptFromBridgeOutcome({ + decision: "dismissed", + })).toEqual({ message: null }); + + const approved = buildReviewPromptFromBridgeOutcome({ + decision: "approved", + approved: true, + agentSwitch: "build", + }); + expect(approved.agent).toBe("build"); + expect(approved.message).toContain("Code Review"); + + const localFeedback = buildReviewPromptFromBridgeOutcome({ + decision: "annotated", + approved: false, + isPRMode: false, + feedback: "Fix these issues.", + agentSwitch: "disabled", + }); + expect(localFeedback.agent).toBeUndefined(); + expect(localFeedback.message).toContain("Fix these issues."); + expect(localFeedback.message).toContain("Please address this feedback."); + + const prFeedback = buildReviewPromptFromBridgeOutcome({ + decision: "annotated", + approved: false, + isPRMode: true, + feedback: "PR comment only.", + }); + expect(prFeedback.message).toBe("PR comment only."); + }); +}); diff --git a/apps/opencode-plugin/cli-bridge.ts b/apps/opencode-plugin/cli-bridge.ts new file mode 100644 index 000000000..4877b9d3b --- /dev/null +++ b/apps/opencode-plugin/cli-bridge.ts @@ -0,0 +1,586 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { parseAnnotateArgs, type ParsedAnnotateArgs } from "@plannotator/shared/annotate-args"; +import { + getAnnotateFileFeedbackPrompt, + getAnnotateMessageFeedbackPrompt, + getReviewApprovedPrompt, + getReviewDeniedSuffix, +} from "@plannotator/shared/prompts"; + +type LogLevel = "info" | "error"; + +interface OpenCodeClient { + app?: { + log?: (entry: { level: LogLevel; message: string }) => unknown; + }; + session?: { + messages?: (input: unknown) => Promise<{ data?: any[] }>; + prompt?: (input: unknown) => Promise; + }; +} + +export interface OpenCodePlanReviewResult { + approved: boolean; + feedback?: string; + savedPath?: string; + agentSwitch?: string; +} + +export interface OpenCodeBridgeAgent { + name: string; + description?: string; + mode?: string; + hidden?: boolean; +} + +export interface OpenCodeBridgeContext { + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + agents?: OpenCodeBridgeAgent[]; +} + +interface RunCliOptions { + client: OpenCodeClient; + args: string[]; + cwd?: string; + input?: string; + readyLabel: string; + extraEnv?: Record; + bridge?: OpenCodeBridgeContext; +} + +interface RunCliResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +interface CliSpawnConfig { + command: string; + args: string[]; + shell: false; +} + +interface CliAnnotateOutcome { + decision?: "approved" | "dismissed" | "annotated"; + feedback?: string; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; +} + +export interface CliReviewOutcome { + decision?: "approved" | "dismissed" | "annotated"; + approved?: boolean; + feedback?: string; + agentSwitch?: string; + isPRMode?: boolean; +} + +export interface RecentAssistantMessage { + messageId: string; + text: string; + timestamp?: string; +} + +function log(client: OpenCodeClient, level: LogLevel, message: string): void { + try { + void client.app?.log?.({ level, message }); + } catch { + // OpenCode logging is best-effort. + } +} + +function getPlannotatorBin(): string { + return process.env.PLANNOTATOR_BIN?.trim() || "plannotator"; +} + +function getWindowsPathCandidates(bin: string, env: NodeJS.ProcessEnv): string[] { + if (path.extname(bin)) return [bin]; + + const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean); + // The Windows installer ships plannotator.exe. Avoid auto-resolving .cmd/.bat + // shims because those require cmd.exe and would reintroduce shell tokenization. + const executableExtensions = extensions.filter((ext) => ext !== ".cmd" && ext !== ".bat"); + const preferred = [".exe", ".com"]; + const orderedExtensions = [ + ...preferred.filter((ext) => executableExtensions.includes(ext)), + ...executableExtensions.filter((ext) => !preferred.includes(ext)), + ]; + + return [...orderedExtensions.map((ext) => `${bin}${ext}`), bin]; +} + +export function resolveWindowsCliCommand(bin: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const pathValue = env.PATH || ""; + if (!pathValue) return undefined; + + const candidates = getWindowsPathCandidates(bin, env); + for (const dir of pathValue.split(path.delimiter)) { + if (!dir) continue; + for (const candidate of candidates) { + const fullPath = path.join(dir, candidate); + if (existsSync(fullPath)) return fullPath; + } + } + + return undefined; +} + +export function buildCliSpawnConfig( + bin: string, + args: string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): CliSpawnConfig { + if (platform === "win32" && !path.isAbsolute(bin)) { + return { + command: resolveWindowsCliCommand(bin, env) || bin, + args, + shell: false, + }; + } + + return { command: bin, args, shell: false }; +} + +function parseLastJson(stdout: string): T { + const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line.startsWith("{")) continue; + return JSON.parse(line) as T; + } + throw new Error("Plannotator CLI did not return JSON."); +} + +export function buildCliBridgeEnv( + bridge: OpenCodeBridgeContext | undefined, +): Record { + return { + ...(bridge?.sharingEnabled !== undefined && { + PLANNOTATOR_SHARE: bridge.sharingEnabled ? "enabled" : "disabled", + }), + ...(bridge?.shareBaseUrl && { PLANNOTATOR_SHARE_URL: bridge.shareBaseUrl }), + ...(bridge?.pasteApiUrl && { PLANNOTATOR_PASTE_URL: bridge.pasteApiUrl }), + }; +} + +function buildBridgePayload(bridge: OpenCodeBridgeContext | undefined): OpenCodeBridgeContext { + return { + ...(bridge?.sharingEnabled !== undefined && { sharingEnabled: bridge.sharingEnabled }), + ...(bridge?.shareBaseUrl && { shareBaseUrl: bridge.shareBaseUrl }), + ...(bridge?.pasteApiUrl && { pasteApiUrl: bridge.pasteApiUrl }), + ...(bridge?.agents && { agents: bridge.agents }), + }; +} + +function logCliWarnings(client: OpenCodeClient, stderr: string): void { + const warningLines = stderr + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /\bwarn(?:ing)?\b/i.test(line)); + + for (const line of warningLines) { + log(client, "info", `[Plannotator] ${line}`); + } +} + +export function formatUserFacingCliStderrLine(line: string): string | undefined { + const trimmed = line.trim(); + if (!trimmed) return undefined; + if (/^Open this link on your local machine to\b/.test(trimmed)) return trimmed; + if (/^https?:\/\/\S+/.test(trimmed)) return trimmed; + if (/^\(.+annotations added in browser\)$/.test(trimmed)) return trimmed; + return undefined; +} + +function createCliStderrForwarder(client: OpenCodeClient) { + let pending = ""; + const forwarded = new Set(); + + const forwardLine = (line: string) => { + const message = formatUserFacingCliStderrLine(line); + if (!message || forwarded.has(message)) return; + forwarded.add(message); + log(client, "info", `[Plannotator] ${message}`); + }; + + return { + push(chunk: string) { + pending += chunk; + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) forwardLine(line); + }, + flush() { + if (!pending) return; + forwardLine(pending); + pending = ""; + }, + }; +} + +function logReadyFile(client: OpenCodeClient, readyFile: string, readyLabel: string, loggedUrls: Set): void { + if (!existsSync(readyFile)) return; + + const contents = readFileSync(readyFile, "utf-8"); + for (const line of contents.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const metadata = JSON.parse(line) as { url?: string }; + if (!metadata.url || loggedUrls.has(metadata.url)) continue; + loggedUrls.add(metadata.url); + log(client, "info", `[Plannotator] Open ${readyLabel}: ${metadata.url}`); + } catch { + // Ignore partial lines while the child process is writing. + } + } +} + +async function runPlannotatorCli(options: RunCliOptions): Promise { + const readyFile = path.join( + tmpdir(), + `plannotator-opencode-${process.pid}-${Date.now()}-${randomUUID()}.jsonl`, + ); + const loggedUrls = new Set(); + const cwd = options.cwd || process.cwd(); + const env = { + ...process.env, + ...options.extraEnv, + ...buildCliBridgeEnv(options.bridge), + OPENCODE: "1", + PLANNOTATOR_ORIGIN: "opencode", + PLANNOTATOR_CWD: cwd, + PLANNOTATOR_READY_FILE: readyFile, + }; + + const bin = getPlannotatorBin(); + const spawnConfig = buildCliSpawnConfig(bin, options.args); + log(options.client, "info", `[Plannotator] Starting ${options.readyLabel}...`); + + return await new Promise((resolve, reject) => { + const child = spawn(spawnConfig.command, spawnConfig.args, { + cwd, + env, + shell: spawnConfig.shell, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + const stderrForwarder = createCliStderrForwarder(options.client); + const interval = setInterval( + () => logReadyFile(options.client, readyFile, options.readyLabel, loggedUrls), + 250, + ); + + if (!child.stdin || !child.stdout || !child.stderr) { + clearInterval(interval); + rmSync(readyFile, { force: true }); + reject(new Error("Failed to open pipes for the plannotator CLI process.")); + return; + } + + child.stdout.setEncoding("utf-8"); + child.stderr.setEncoding("utf-8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + stderrForwarder.push(chunk); + }); + child.on("error", (error: NodeJS.ErrnoException) => { + clearInterval(interval); + stderrForwarder.flush(); + logReadyFile(options.client, readyFile, options.readyLabel, loggedUrls); + rmSync(readyFile, { force: true }); + if (error.code === "ENOENT") { + reject(new Error("Could not find the plannotator CLI. Install it with: curl -fsSL https://plannotator.ai/install.sh | bash")); + return; + } + reject(error); + }); + child.on("close", (exitCode) => { + clearInterval(interval); + stderrForwarder.flush(); + logReadyFile(options.client, readyFile, options.readyLabel, loggedUrls); + rmSync(readyFile, { force: true }); + resolve({ stdout, stderr, exitCode }); + }); + + child.stdin.end(options.input ?? ""); + }); +} + +export function buildAnnotateCliArgs(parsed: ParsedAnnotateArgs): string[] { + const args = ["annotate", parsed.rawFilePath, "--json"]; + if (parsed.gate) args.push("--gate"); + if (parsed.renderHtml) args.push("--render-html"); + if (parsed.noJina) args.push("--no-jina"); + return args; +} + +export async function runCliPlanReview(input: { + client: OpenCodeClient; + planContent: string; + cwd?: string; + timeoutSeconds: number | null; + bridge?: OpenCodeBridgeContext; +}): Promise { + const result = await runPlannotatorCli({ + client: input.client, + args: ["opencode-plan"], + cwd: input.cwd, + input: JSON.stringify({ + plan: input.planContent, + timeoutSeconds: input.timeoutSeconds, + ...buildBridgePayload(input.bridge), + }), + readyLabel: "plan review", + bridge: input.bridge, + }); + + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || `Plannotator CLI exited with code ${result.exitCode}`); + } + + logCliWarnings(input.client, result.stderr); + return parseLastJson(result.stdout); +} + +async function injectSessionPrompt( + client: OpenCodeClient, + sessionId: string | undefined, + text: string, + options?: { agent?: string; noReply?: boolean }, +): Promise { + if (!sessionId || !text.trim()) return; + try { + await client.session?.prompt?.({ + path: { id: sessionId }, + body: { + ...(options?.agent && { agent: options.agent }), + ...(options?.noReply && { noReply: true }), + parts: [{ type: "text", text }], + }, + }); + } catch { + // Session may be unavailable or busy. + } +} + +export async function getRecentAssistantMessages( + client: OpenCodeClient, + sessionId: string, + limit = 25, +): Promise { + const messagesResponse = await client.session?.messages?.({ + path: { id: sessionId }, + }); + const messages = messagesResponse?.data; + if (!messages) return []; + + const recentMessages: RecentAssistantMessage[] = []; + for (let i = messages.length - 1; i >= 0; i--) { + if (recentMessages.length >= limit) break; + const msg = messages[i]; + if (msg.info?.role !== "assistant") continue; + const textParts = (msg.parts ?? []) + .filter((part: any) => part.type === "text" && part.text?.trim()) + .map((part: any) => part.text); + if (textParts.length === 0) continue; + recentMessages.push({ + messageId: msg.info?.id ?? `opencode-${i}`, + text: textParts.join("\n"), + timestamp: msg.info?.time?.created ? new Date(msg.info.time.created).toISOString() : undefined, + }); + } + + return recentMessages; +} + +export function buildReviewPromptFromBridgeOutcome(outcome: CliReviewOutcome): { + message: string | null; + agent?: string; +} { + if (outcome.decision === "dismissed") return { message: null }; + + const shouldSwitchAgent = outcome.agentSwitch && outcome.agentSwitch !== "disabled"; + const targetAgent = shouldSwitchAgent ? outcome.agentSwitch : undefined; + + if (outcome.approved || outcome.decision === "approved") { + return { + message: getReviewApprovedPrompt("opencode"), + ...(targetAgent && { agent: targetAgent }), + }; + } + + if (!outcome.feedback?.trim()) { + return { + message: null, + ...(targetAgent && { agent: targetAgent }), + }; + } + + return { + message: outcome.isPRMode + ? outcome.feedback + : `${outcome.feedback}${getReviewDeniedSuffix("opencode")}`, + ...(targetAgent && { agent: targetAgent }), + }; +} + +function getAnnotateFileHeader(filePath: string, cwd?: string): "File" | "Folder" { + if (/^https?:\/\//i.test(filePath)) return "File"; + + try { + const resolved = path.isAbsolute(filePath) + ? filePath + : path.resolve(cwd || process.cwd(), filePath); + return statSync(resolved).isDirectory() ? "Folder" : "File"; + } catch { + return "File"; + } +} + +export async function handleCliCommand(input: { + command: string; + client: OpenCodeClient; + sessionId?: string; + rawArgs: string; + cwd?: string; + bridge?: OpenCodeBridgeContext; +}): Promise { + try { + if (input.command === "plannotator-review") { + const result = await runPlannotatorCli({ + client: input.client, + args: ["opencode-review"], + cwd: input.cwd, + input: JSON.stringify({ + arguments: input.rawArgs, + ...buildBridgePayload(input.bridge), + }), + readyLabel: "code review", + bridge: input.bridge, + }); + if (result.exitCode !== 0) { + log(input.client, "error", result.stderr.trim() || `Plannotator CLI exited with code ${result.exitCode}`); + return; + } + + logCliWarnings(input.client, result.stderr); + const outcome = parseLastJson(result.stdout); + const prompt = buildReviewPromptFromBridgeOutcome(outcome); + if (prompt.message) { + await injectSessionPrompt(input.client, input.sessionId, prompt.message, { + agent: prompt.agent, + }); + } + return; + } + + if (input.command === "plannotator-annotate") { + const parsed = parseAnnotateArgs(input.rawArgs); + if (!parsed.filePath) { + log(input.client, "error", "Usage: /plannotator-annotate [--no-jina] [--gate] [--json]"); + return; + } + + const result = await runPlannotatorCli({ + client: input.client, + args: buildAnnotateCliArgs(parsed), + cwd: input.cwd, + readyLabel: "annotation UI", + bridge: input.bridge, + }); + if (result.exitCode !== 0) { + log(input.client, "error", result.stderr.trim() || `Plannotator CLI exited with code ${result.exitCode}`); + return; + } + + logCliWarnings(input.client, result.stderr); + const outcome = parseLastJson(result.stdout); + if (outcome.decision === "annotated" && outcome.feedback) { + await injectSessionPrompt( + input.client, + input.sessionId, + getAnnotateFileFeedbackPrompt("opencode", undefined, { + fileHeader: getAnnotateFileHeader(parsed.filePath, input.cwd), + filePath: parsed.filePath, + feedback: outcome.feedback, + }), + ); + } + return; + } + + if (input.command === "plannotator-last") { + if (!input.sessionId) { + log(input.client, "error", "No active session."); + return; + } + + const recentMessages = await getRecentAssistantMessages(input.client, input.sessionId); + if (recentMessages.length === 0) { + log(input.client, "error", "No assistant message found in session."); + return; + } + + const parsed = parseAnnotateArgs(input.rawArgs); + const result = await runPlannotatorCli({ + client: input.client, + args: ["opencode-annotate-last"], + cwd: input.cwd, + input: JSON.stringify({ + gate: parsed.gate, + recentMessages, + ...buildBridgePayload(input.bridge), + }), + readyLabel: "annotation UI", + bridge: input.bridge, + }); + if (result.exitCode !== 0) { + log(input.client, "error", result.stderr.trim() || `Plannotator CLI exited with code ${result.exitCode}`); + return; + } + + logCliWarnings(input.client, result.stderr); + const outcome = parseLastJson(result.stdout); + if (outcome.decision === "annotated" && outcome.feedback) { + await injectSessionPrompt( + input.client, + input.sessionId, + getAnnotateMessageFeedbackPrompt("opencode", undefined, { feedback: outcome.feedback }), + ); + } + return; + } + + if (input.command === "plannotator-archive") { + const result = await runPlannotatorCli({ + client: input.client, + args: ["archive"], + cwd: input.cwd, + readyLabel: "archive", + bridge: input.bridge, + }); + if (result.exitCode !== 0) { + log(input.client, "error", result.stderr.trim() || `Plannotator CLI exited with code ${result.exitCode}`); + } else { + logCliWarnings(input.client, result.stderr); + } + } + } catch (error) { + log(input.client, "error", `[Plannotator] ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 7e62b6d3f..6f56fb0bc 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -121,9 +121,7 @@ export async function handleReviewCommand( opencodeClient: client, onReady: (url, isRemote, port) => { handleReviewServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } + client.app.log({ level: "info", message: `[Plannotator] Open code review: ${url}` }); }, }); @@ -172,14 +170,14 @@ export async function handleAnnotateCommand( // @ts-ignore - Event properties contain arguments const rawArgs = event.properties?.arguments || event.arguments || ""; - // Split --gate / --json out of the args; rest is the file path. + // Split known annotate flags out of the args; rest is the file path. // --json is accepted silently (OpenCode writes to session, not stdout). // parseAnnotateArgs strips leading @ on filePath (reference-mode convention). // `rawFilePath` preserves it for the scoped-package markdown fallback. - const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag } = parseAnnotateArgs(rawArgs); + const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, noJina } = parseAnnotateArgs(rawArgs); if (!filePath) { - client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--gate] [--json]" }); + client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--no-jina] [--gate] [--json]" }); return; } @@ -196,7 +194,7 @@ export async function handleAnnotateCommand( const isUrl = /^https?:\/\//i.test(filePath); if (isUrl) { - const useJina = resolveUseJina(false, loadConfig()); + const useJina = resolveUseJina(noJina, loadConfig()); client.app.log({ level: "info", message: `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...` }); try { const result = await urlToMarkdown(filePath, { useJina }); @@ -295,9 +293,7 @@ export async function handleAnnotateCommand( htmlContent, onReady: (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } + client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` }); }, }); @@ -406,9 +402,7 @@ export async function handleAnnotateLastCommand( htmlContent, onReady: (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } + client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` }); }, }); @@ -442,9 +436,7 @@ export async function handleArchiveCommand( htmlContent, onReady: (url, isRemote, port) => { handleServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } + client.app.log({ level: "info", message: `[Plannotator] Open archive: ${url}` }); }, }); diff --git a/apps/opencode-plugin/embedded.ts b/apps/opencode-plugin/embedded.ts new file mode 100644 index 000000000..23182493a --- /dev/null +++ b/apps/opencode-plugin/embedded.ts @@ -0,0 +1,113 @@ +import { recoverNativeFetchConstructors } from "./fetch-shim"; + +export interface EmbeddedPlanReviewInput { + client: any; + planContent: string; + sharingEnabled: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + htmlContent: string; + timeoutSeconds: number | null; + logReady: (url: string, isRemote: boolean, port: number) => void; +} + +export interface EmbeddedPlanReviewResult { + approved: boolean; + feedback?: string; + savedPath?: string; + agentSwitch?: string; +} + +async function loadPlanServer() { + recoverNativeFetchConstructors(); + return await import("@plannotator/server"); +} + +async function loadCommandHandlers() { + recoverNativeFetchConstructors(); + return await import("./commands"); +} + +export async function runEmbeddedPlanReview( + input: EmbeddedPlanReviewInput, +): Promise { + const { startPlannotatorServer, handleServerReady } = await loadPlanServer(); + const server = await startPlannotatorServer({ + plan: input.planContent, + origin: "opencode", + sharingEnabled: input.sharingEnabled, + shareBaseUrl: input.shareBaseUrl, + pasteApiUrl: input.pasteApiUrl, + htmlContent: input.htmlContent, + opencodeClient: input.client, + onReady: async (url, isRemote, port) => { + await handleServerReady(url, isRemote, port); + input.logReady(url, isRemote, port); + }, + }); + + const timeoutMs = input.timeoutSeconds === null ? null : input.timeoutSeconds * 1000; + const result = timeoutMs === null + ? await server.waitForDecision() + : await new Promise>>((resolve) => { + const timeoutId = setTimeout( + () => + resolve({ + approved: false, + feedback: `[Plannotator] No response within ${input.timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`, + }), + timeoutMs, + ); + + server.waitForDecision().then((decision) => { + clearTimeout(timeoutId); + resolve(decision); + }); + }); + + await Bun.sleep(1500); + server.stop(); + return result; +} + +export async function handleEmbeddedCommand( + command: string, + event: any, + deps: { + client: any; + htmlContent: string; + reviewHtmlContent: string; + getSharingEnabled: () => Promise; + getShareBaseUrl: () => string | undefined; + getPasteApiUrl: () => string | undefined; + directory?: string; + }, +): Promise<{ feedback?: string | null }> { + const { + handleReviewCommand, + handleAnnotateCommand, + handleAnnotateLastCommand, + handleArchiveCommand, + } = await loadCommandHandlers(); + + if (command === "plannotator-last") { + return { feedback: await handleAnnotateLastCommand(event, deps) }; + } + + if (command === "plannotator-annotate") { + await handleAnnotateCommand(event, deps); + return {}; + } + + if (command === "plannotator-review") { + await handleReviewCommand(event, deps); + return {}; + } + + if (command === "plannotator-archive") { + await handleArchiveCommand(event, deps); + return {}; + } + + return {}; +} diff --git a/apps/opencode-plugin/export-shape.test.ts b/apps/opencode-plugin/export-shape.test.ts new file mode 100644 index 000000000..6f6f742df --- /dev/null +++ b/apps/opencode-plugin/export-shape.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; + +describe("OpenCode plugin entry export shape", () => { + test("exports only the default plugin function", async () => { + const mod = await import("./index"); + const functionExports = Object.entries(mod) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name); + + expect(functionExports).toEqual(["default"]); + }); +}); diff --git a/apps/opencode-plugin/fetch-shim.ts b/apps/opencode-plugin/fetch-shim.ts new file mode 100644 index 000000000..bc87ab02d --- /dev/null +++ b/apps/opencode-plugin/fetch-shim.ts @@ -0,0 +1,24 @@ +export function recoverNativeFetchConstructors(): void { + // OpenCode's @hono/node-server can patch global.Response/Request with + // polyfills that Bun.serve() rejects. Recover the native constructors from + // the polyfill prototype chain before loading Bun-only server modules. + if (typeof Response === "undefined" || typeof Request === "undefined") return; + + const responseProto = Object.getPrototypeOf(Response.prototype); + if ( + responseProto?.constructor && + responseProto.constructor !== Response && + responseProto.constructor !== Object + ) { + globalThis.Response = responseProto.constructor; + } + + const requestProto = Object.getPrototypeOf(Request.prototype); + if ( + requestProto?.constructor && + requestProto.constructor !== Request && + requestProto.constructor !== Object + ) { + globalThis.Request = requestProto.constructor; + } +} diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index aebeeef8f..90594587f 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -16,45 +16,10 @@ */ import { type Plugin, tool } from "@opencode-ai/plugin"; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; -import path from "path"; -import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; - -// OpenCode's @hono/node-server patches global.Response with a polyfill that -// Bun.serve() doesn't accept (it checks native type tags, not instanceof). -// This happens in "opencode web" and "opencode serve" modes, where -// createAdaptorServer() runs before plugins load. Recover the native Response -// from the polyfill's prototype chain — hono sets up: -// Object.setPrototypeOf(Response2.prototype, GlobalResponse.prototype) -// so the parent prototype's constructor IS the original native Response. -const _proto = Object.getPrototypeOf(Response.prototype); -if (_proto?.constructor && _proto.constructor !== Response && _proto.constructor !== Object) { - globalThis.Response = _proto.constructor; - // Also fix Request — hono patches both with the same pattern - const _reqProto = Object.getPrototypeOf(Request.prototype); - if (_reqProto?.constructor && _reqProto.constructor !== Request && _reqProto.constructor !== Object) { - globalThis.Request = _reqProto.constructor; - } -} -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - startAnnotateServer, - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; -import { - handleReviewCommand, - handleAnnotateCommand, - handleAnnotateLastCommand, - handleArchiveCommand, - type CommandDeps, -} from "./commands"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { getPlanDeniedPrompt, getPlanApprovedPrompt, @@ -79,19 +44,33 @@ import { shouldModifyPrompts, shouldRegisterSubmitPlan, shouldRejectSubmitPlanForAgent, + type RuntimeMode, type PlannotatorOpenCodeOptions, } from "./workflow"; +import { + applyEdits, + formatWithLineNumbers, + getPlanBackingPath, + validateEdits, +} from "./plan-edits"; +import { + handleCliCommand, + runCliPlanReview, + type OpenCodeBridgeContext, + type OpenCodePlanReviewResult, +} from "./cli-bridge"; // Lazy-load HTML at first use instead of embedding in the bundle. // The two SPA files are ~20 MB combined — inlining them as string literals // adds ~160ms to module parse time (see GitHub issue #410). let _planHtml: string | null = null; let _reviewHtml: string | null = null; +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); function resolveBundledHtmlPath(filename: string): string { const candidates = [ - path.join(import.meta.dir, filename), - path.join(import.meta.dir, "..", filename), + path.join(moduleDir, filename), + path.join(moduleDir, "..", filename), ]; for (const candidate of candidates) { @@ -120,112 +99,6 @@ function getReviewHtml(): string { const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours const MAX_PLAN_SIZE = 5 * 1024 * 1024; // 5MB -// ── Edit-based plan management ──────────────────────────────────────────── - -interface PlanEdit { - start: number; - end?: number | null; - content: string; -} - -/** - * Backing file for the current plan. Stored outside the workspace in - * `~/.plannotator/active/{project}/_active-plan.md` so it never appears - * in git status or editor file trees. Managed entirely by the plugin; - * the agent never sees or touches this file directly. - */ -export function getPlanBackingPath(project: string): string { - return path.join(getPlannotatorDataDir(), "active", project, "_active-plan.md"); -} - -/** - * Apply line-range edits to a plan stored as an array of lines. - * - * Edit semantics: - * - start/end are 1-indexed line numbers (inclusive) - * - end omitted or null: replace from start through end of file - * (on first call with start=1, this writes the entire plan) - * - content="" with start/end: delete those lines - * - edits are applied in order; line numbers refer to the document - * state BEFORE any edits in this batch (offsets are adjusted internally) - */ -export function applyEdits(existingLines: string[], edits: PlanEdit[]): string[] { - // Sort by start ascending so offset adjustment works correctly - const sorted = [...edits].sort((a, b) => a.start - b.start); - const lines = [...existingLines]; - let offset = 0; - - for (const edit of sorted) { - const start = edit.start - 1 + offset; // convert to 0-indexed + adjust - const end = edit.end != null - ? edit.end + offset // end is inclusive, so this becomes the exclusive upper bound - : lines.length; // null/omitted = through end of file - - const newLines = edit.content ? edit.content.split("\n") : []; - const removedCount = end - start; - lines.splice(start, removedCount, ...newLines); - offset += newLines.length - removedCount; - } - - return lines; -} - -/** - * Validate a batch of edits against the current file state. - * Returns an error string if invalid, or null if all edits are acceptable. - */ -export function validateEdits(existingLines: string[], edits: PlanEdit[]): string | null { - const lineCount = existingLines.length; - - for (const edit of edits) { - if (!Number.isInteger(edit.start) || edit.start < 1) { - return `start must be a positive integer >= 1, got ${edit.start}`; - } - if (edit.start > lineCount + 1) { - return `start (${edit.start}) exceeds file length + 1 (${lineCount + 1})`; - } - if (edit.end != null) { - if (!Number.isInteger(edit.end) || edit.end < edit.start) { - return `end (${edit.end}) must be >= start (${edit.start})`; - } - // On an empty file (lineCount === 0) every edit is a pure insert; - // end is semantically meaningless and applyEdits handles it via splice - // clamping. Rejecting here breaks first-call payloads where the agent - // or framework includes end (see #742). - if (edit.end > lineCount && lineCount > 0) { - return `end (${edit.end}) exceeds file length (${lineCount})`; - } - } - } - - // Check for overlapping ranges (sorted by start ascending) - const sorted = [...edits].sort((a, b) => a.start - b.start); - for (let i = 1; i < sorted.length; i++) { - const prev = sorted[i - 1]; - const curr = sorted[i]; - // Appending edits (start > lineCount) have no range that can overlap - if (prev.start > lineCount) continue; - const prevEnd = prev.end ?? lineCount; - if (curr.start <= prevEnd) { - return `edits overlap: [${prev.start},${prevEnd}] and [${curr.start},${curr.end ?? "end"}]`; - } - } - - return null; -} - -/** - * Format the plan content with line numbers for the agent's reference. - * Returned in the tool response so the agent can track line positions. - */ -export function formatWithLineNumbers(content: string): string { - const lines = content.split("\n"); - const width = String(lines.length).length; - return lines - .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`) - .join("\n"); -} - // ── Planning prompt ─────────────────────────────────────────────────────── /** @@ -294,12 +167,122 @@ function getLastUserAgentFromMessages(messages: any[] | undefined): string | und return undefined; } -export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpenCodeOptions) => { +function getBunRuntime(): { serve?: unknown; sleep?: (ms: number) => Promise } | undefined { + return (globalThis as typeof globalThis & { + Bun?: { serve?: unknown; sleep?: (ms: number) => Promise }; + }).Bun; +} + +function hasEmbeddedRuntime(): boolean { + return typeof getBunRuntime()?.serve === "function"; +} + +function shouldUseEmbeddedRuntime(runtime: RuntimeMode): boolean { + return runtime !== "cli" && hasEmbeddedRuntime(); +} + +function getEmbeddedRuntimeError(): string { + return "runtime \"embedded\" requires a Bun-hosted OpenCode plugin runtime. Use runtime \"auto\" or \"cli\" with this OpenCode host."; +} + +function logPlannotatorReady(client: any, label: string, url: string): void { + try { + void client.app.log({ level: "info", message: `[Plannotator] Open ${label}: ${url}` }); + } catch { + // OpenCode logging is best-effort. + } +} + +type EmbeddedRuntimeModule = { + runEmbeddedPlanReview: (input: { + client: any; + planContent: string; + sharingEnabled: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + htmlContent: string; + timeoutSeconds: number | null; + logReady: (url: string, isRemote: boolean, port: number) => void; + }) => Promise; + handleEmbeddedCommand: ( + command: string, + event: any, + deps: { + client: any; + htmlContent: string; + reviewHtmlContent: string; + getSharingEnabled: () => Promise; + getShareBaseUrl: () => string | undefined; + getPasteApiUrl: () => string | undefined; + directory?: string; + }, + ) => Promise<{ feedback?: string | null }>; +}; + +async function importEmbeddedRuntime(): Promise { + 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; +} + +async function runPlanReview(input: { + client: any; + runtime: RuntimeMode; + planContent: string; + sharingEnabled: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + htmlContent: string; + timeoutSeconds: number | null; + cwd?: string; + bridge: OpenCodeBridgeContext; +}): Promise { + if (input.runtime === "embedded" && !hasEmbeddedRuntime()) { + throw new Error(getEmbeddedRuntimeError()); + } + + if (shouldUseEmbeddedRuntime(input.runtime)) { + 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: input.htmlContent, + timeoutSeconds: input.timeoutSeconds, + logReady: (url) => logPlannotatorReady(input.client, "plan review", url), + }); + } catch (error) { + if (input.runtime === "embedded") throw error; + try { + void input.client.app.log({ + level: "error", + message: `[Plannotator] Embedded runtime unavailable; falling back to CLI: ${error instanceof Error ? error.message : String(error)}`, + }); + } catch {} + } + } + + return await runCliPlanReview({ + client: input.client, + planContent: input.planContent, + cwd: input.cwd, + timeoutSeconds: input.timeoutSeconds, + bridge: input.bridge, + }); +} + +const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpenCodeOptions) => { const workflowOptions = normalizeWorkflowOptions(rawOptions); // Preload HTML in background — populates the sync cache before first use - Bun.file(resolveBundledHtmlPath("plannotator.html")).text().then(h => { _planHtml = h; }); - Bun.file(resolveBundledHtmlPath("review-editor.html")).text().then(h => { _reviewHtml = h; }); + readFile(resolveBundledHtmlPath("plannotator.html"), "utf-8").then(h => { _planHtml = h; }).catch(() => {}); + readFile(resolveBundledHtmlPath("review-editor.html"), "utf-8").then(h => { _reviewHtml = h; }).catch(() => {}); let cachedAgents: any[] | null = null; @@ -325,6 +308,29 @@ export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpe return process.env.PLANNOTATOR_PASTE_URL || undefined; } + async function getOpenCodeAgents(): Promise { + try { + if (!cachedAgents) { + const response = await ctx.client.app.agents({ + query: { directory: ctx.directory }, + }); + cachedAgents = response.data ?? []; + } + return cachedAgents; + } catch { + return undefined; + } + } + + async function getBridgeContext(): Promise { + return { + sharingEnabled: await getSharingEnabled(), + shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), + agents: await getOpenCodeAgents(), + }; + } + function getPlanTimeoutSeconds(): number | null { const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim(); if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS; @@ -496,44 +502,70 @@ Do NOT proceed with implementation until your plan is approved.`); output.parts.length = 0; - const deps: CommandDeps = { - client: ctx.client, - htmlContent: getPlanHtml(), - reviewHtmlContent: getReviewHtml(), - getSharingEnabled, - getShareBaseUrl, - getPasteApiUrl, - directory: ctx.directory, - }; // input.arguments is the raw tail string from OpenCode's command dispatcher — // needed so --gate / --json reach the handlers' parseAnnotateArgs. const event = { properties: { sessionID: input.sessionID, arguments: input.arguments }, }; - if (cmd === "plannotator-last") { - const feedback = await handleAnnotateLastCommand(event, deps); - if (feedback) { + if (shouldUseEmbeddedRuntime(workflowOptions.runtime)) { + try { + const embedded = await importEmbeddedRuntime(); + const deps = { + client: ctx.client, + htmlContent: getPlanHtml(), + reviewHtmlContent: getReviewHtml(), + getSharingEnabled, + getShareBaseUrl, + getPasteApiUrl, + directory: ctx.directory, + }; + const result = await embedded.handleEmbeddedCommand(cmd, event, deps); + if (cmd === "plannotator-last" && result.feedback) { + try { + await ctx.client.session.prompt({ + path: { id: input.sessionID }, + body: { + parts: [{ + type: "text", + text: getAnnotateMessageFeedbackPrompt("opencode", undefined, { feedback: result.feedback }), + }], + }, + }); + } catch { + // Session may not be available + } + } + return; + } catch (error) { + if (workflowOptions.runtime === "embedded") throw error; try { - await ctx.client.session.prompt({ - path: { id: input.sessionID }, - body: { - parts: [{ - type: "text", - text: getAnnotateMessageFeedbackPrompt("opencode", undefined, { feedback }), - }], - }, + void ctx.client.app.log({ + level: "error", + message: `[Plannotator] Embedded runtime unavailable; falling back to CLI: ${error instanceof Error ? error.message : String(error)}`, }); - } catch { - // Session may not be available - } + } catch {} } + } + + if (workflowOptions.runtime === "embedded" && !hasEmbeddedRuntime()) { + try { + void ctx.client.app.log({ + level: "error", + message: `[Plannotator] ${getEmbeddedRuntimeError()}`, + }); + } catch {} return; } - if (cmd === "plannotator-annotate") return handleAnnotateCommand(event, deps); - if (cmd === "plannotator-review") return handleReviewCommand(event, deps); - if (cmd === "plannotator-archive") return handleArchiveCommand(event, deps); + await handleCliCommand({ + command: cmd, + client: ctx.client, + sessionId: input.sessionID, + rawArgs: input.arguments ?? "", + cwd: ctx.directory, + bridge: await getBridgeContext(), + }); }, }; @@ -603,45 +635,24 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo // Write backing file writeFileSync(backingPath, planContent, "utf-8"); - const sharingEnabled = await getSharingEnabled(); - const server = await startPlannotatorServer({ - plan: planContent, - origin: "opencode", - sharingEnabled, - shareBaseUrl: getShareBaseUrl(), - pasteApiUrl: getPasteApiUrl(), - htmlContent: getPlanHtml(), - opencodeClient: ctx.client, - onReady: async (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - if (isRemote) { - ctx.client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, - }); - const timeoutSeconds = getPlanTimeoutSeconds(); - const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000; - - const result = timeoutMs === null - ? await server.waitForDecision() - : await new Promise>>((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(); + let result: OpenCodePlanReviewResult; + try { + result = await runPlanReview({ + client: ctx.client, + runtime: workflowOptions.runtime, + planContent, + sharingEnabled: await getSharingEnabled(), + shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), + htmlContent: getPlanHtml(), + timeoutSeconds, + cwd: ctx.directory, + bridge: await getBridgeContext(), + }); + } catch (error) { + return `[Plannotator] Failed to open plan review: ${error instanceof Error ? error.message : String(error)}`; + } if (result.approved) { // Clean up backing file after approval diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index 30792e7cc..70c0593eb 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -1,6 +1,7 @@ { "name": "@plannotator/opencode", "version": "0.19.27", + "type": "module", "description": "Plannotator plugin for OpenCode - interactive plan review with visual annotation", "author": "backnotprop", "license": "MIT OR Apache-2.0", @@ -30,7 +31,7 @@ "review-editor.html" ], "scripts": { - "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", + "build": "mkdir -p dist && cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build embedded.ts --outfile dist/embedded.js --target bun --external @opencode-ai/plugin && bun build index.ts --outfile dist/index.js --target node", "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, diff --git a/apps/opencode-plugin/plan-edits.ts b/apps/opencode-plugin/plan-edits.ts new file mode 100644 index 000000000..de566ac28 --- /dev/null +++ b/apps/opencode-plugin/plan-edits.ts @@ -0,0 +1,103 @@ +import path from "node:path"; +import { getPlannotatorDataDir } from "@plannotator/shared/data-dir"; + +export interface PlanEdit { + start: number; + end?: number | null; + content: string; +} + +/** + * Backing file for the current plan. Stored outside the workspace in + * `~/.plannotator/active/{project}/_active-plan.md` so it never appears + * in git status or editor file trees. Managed entirely by the plugin; + * the agent never sees or touches this file directly. + */ +export function getPlanBackingPath(project: string): string { + return path.join(getPlannotatorDataDir(), "active", project, "_active-plan.md"); +} + +/** + * Apply line-range edits to a plan stored as an array of lines. + * + * Edit semantics: + * - start/end are 1-indexed line numbers (inclusive) + * - end omitted or null: replace from start through end of file + * (on first call with start=1, this writes the entire plan) + * - content="" with start/end: delete those lines + * - edits are applied in order; line numbers refer to the document + * state BEFORE any edits in this batch (offsets are adjusted internally) + */ +export function applyEdits(existingLines: string[], edits: PlanEdit[]): string[] { + const sorted = [...edits].sort((a, b) => a.start - b.start); + const lines = [...existingLines]; + let offset = 0; + + for (const edit of sorted) { + const start = edit.start - 1 + offset; + const end = edit.end != null + ? edit.end + offset + : lines.length; + + const newLines = edit.content ? edit.content.split("\n") : []; + const removedCount = end - start; + lines.splice(start, removedCount, ...newLines); + offset += newLines.length - removedCount; + } + + return lines; +} + +/** + * Validate a batch of edits against the current file state. + * Returns an error string if invalid, or null if all edits are acceptable. + */ +export function validateEdits(existingLines: string[], edits: PlanEdit[]): string | null { + const lineCount = existingLines.length; + + for (const edit of edits) { + if (!Number.isInteger(edit.start) || edit.start < 1) { + return `start must be a positive integer >= 1, got ${edit.start}`; + } + if (edit.start > lineCount + 1) { + return `start (${edit.start}) exceeds file length + 1 (${lineCount + 1})`; + } + if (edit.end != null) { + if (!Number.isInteger(edit.end) || edit.end < edit.start) { + return `end (${edit.end}) must be >= start (${edit.start})`; + } + // On an empty file (lineCount === 0) every edit is a pure insert; + // end is semantically meaningless and applyEdits handles it via splice + // clamping. Rejecting here breaks first-call payloads where the agent + // or framework includes end (see #742). + if (edit.end > lineCount && lineCount > 0) { + return `end (${edit.end}) exceeds file length (${lineCount})`; + } + } + } + + const sorted = [...edits].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]; + const curr = sorted[i]; + if (prev.start > lineCount) continue; + const prevEnd = prev.end ?? lineCount; + if (curr.start <= prevEnd) { + return `edits overlap: [${prev.start},${prevEnd}] and [${curr.start},${curr.end ?? "end"}]`; + } + } + + return null; +} + +/** + * Format the plan content with line numbers for the agent's reference. + * Returned in the tool response so the agent can track line positions. + */ +export function formatWithLineNumbers(content: string): string { + const lines = content.split("\n"); + const width = String(lines.length).length; + return lines + .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`) + .join("\n"); +} diff --git a/apps/opencode-plugin/submit-plan.test.ts b/apps/opencode-plugin/submit-plan.test.ts index 0842556a2..e7426dff0 100644 --- a/apps/opencode-plugin/submit-plan.test.ts +++ b/apps/opencode-plugin/submit-plan.test.ts @@ -6,7 +6,7 @@ import { formatWithLineNumbers, getPlanBackingPath, validateEdits, -} from "./index"; +} from "./plan-edits"; // ── applyEdits ───────────────────────────────────────────────────────────── diff --git a/apps/opencode-plugin/workflow.test.ts b/apps/opencode-plugin/workflow.test.ts index bef37cfc2..56df701ed 100644 --- a/apps/opencode-plugin/workflow.test.ts +++ b/apps/opencode-plugin/workflow.test.ts @@ -25,6 +25,13 @@ describe("normalizeWorkflowOptions", () => { expect(options.workflow).toBe("plan-agent"); }); + test("defaults runtime to auto and accepts explicit runtime modes", () => { + expect(normalizeWorkflowOptions(undefined).runtime).toBe("auto"); + expect(normalizeWorkflowOptions({ runtime: "cli" }).runtime).toBe("cli"); + expect(normalizeWorkflowOptions({ runtime: "embedded" }).runtime).toBe("embedded"); + expect(normalizeWorkflowOptions({ runtime: "wat" }).runtime).toBe("auto"); + }); + test("always includes plan and adds trimmed unique planning agents", () => { const options = normalizeWorkflowOptions({ workflow: "plan-agent", @@ -134,6 +141,34 @@ describe("applyWorkflowConfig", () => { expect(config.agent.build.permission.submit_plan).toBe("deny"); }); + test("plan-agent mode ignores malformed primary_tools instead of spreading it", () => { + const config: any = { + experimental: { + primary_tools: "bash", + other: true, + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions(undefined), false); + + expect(config.experimental).toEqual({ + primary_tools: ["submit_plan"], + other: true, + }); + }); + + test("plan-agent mode filters and deduplicates primary_tools", () => { + const config: any = { + experimental: { + primary_tools: [" bash ", "bash", "", 123, "submit_plan"], + }, + }; + + applyWorkflowConfig(config, normalizeWorkflowOptions(undefined), false); + + expect(config.experimental.primary_tools).toEqual(["bash", "submit_plan"]); + }); + test("plan-agent mode preserves user agent fields and adds custom planning agents", () => { const config: any = { agent: { diff --git a/apps/opencode-plugin/workflow.ts b/apps/opencode-plugin/workflow.ts index 5acb12356..3f2fe3acb 100644 --- a/apps/opencode-plugin/workflow.ts +++ b/apps/opencode-plugin/workflow.ts @@ -1,20 +1,25 @@ import { normalizeEditPermission } from "./plan-mode"; export type WorkflowMode = "manual" | "user-managed" | "plan-agent" | "all-agents"; +export type RuntimeMode = "auto" | "embedded" | "cli"; export interface PlannotatorOpenCodeOptions { workflow?: unknown; planningAgents?: unknown; + runtime?: unknown; } export interface NormalizedWorkflowOptions { workflow: WorkflowMode; planningAgents: string[]; planningAgentSet: Set; + runtime: RuntimeMode; } const WORKFLOWS = new Set(["manual", "user-managed", "plan-agent", "all-agents"]); +const RUNTIMES = new Set(["auto", "embedded", "cli"]); const DEFAULT_WORKFLOW: WorkflowMode = "plan-agent"; +const DEFAULT_RUNTIME: RuntimeMode = "auto"; const DEFAULT_PLANNING_AGENTS = ["plan"]; const BUILTIN_PLAN_AGENT = "plan"; @@ -26,7 +31,7 @@ type AgentConfig = { type OpenCodeConfig = { experimental?: { - primary_tools?: string[]; + primary_tools?: unknown; [key: string]: any; }; agent?: Record; @@ -44,13 +49,22 @@ export function normalizeWorkflowOptions( : DEFAULT_WORKFLOW; const planningAgents = normalizePlanningAgents(rawOptions?.planningAgents); + const runtime = normalizeRuntime(rawOptions?.runtime); return { workflow, planningAgents, planningAgentSet: new Set(planningAgents), + runtime, }; } +function normalizeRuntime(value: unknown): RuntimeMode { + const rawRuntime = typeof value === "string" ? value.trim() : ""; + return RUNTIMES.has(rawRuntime as RuntimeMode) + ? rawRuntime as RuntimeMode + : DEFAULT_RUNTIME; +} + function normalizePlanningAgents(value: unknown): string[] { const seen = new Set(); const agents: string[] = [BUILTIN_PLAN_AGENT]; @@ -69,6 +83,22 @@ function normalizePlanningAgents(value: unknown): string[] { return agents; } +function normalizePrimaryTools(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + const seen = new Set(); + const tools: string[] = []; + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + tools.push(trimmed); + } + + return tools; +} + export function isPlanningAgent( agentName: string | undefined, options: NormalizedWorkflowOptions, @@ -121,13 +151,13 @@ export function applyWorkflowConfig( if (options.workflow === "manual" || options.workflow === "user-managed") return; if (!allowSubagents) { - const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []; - if (!existingPrimaryTools.includes("submit_plan")) { - opencodeConfig.experimental = { - ...opencodeConfig.experimental, - primary_tools: [...existingPrimaryTools, "submit_plan"], - }; - } + const existingPrimaryTools = normalizePrimaryTools(opencodeConfig.experimental?.primary_tools); + opencodeConfig.experimental = { + ...opencodeConfig.experimental, + primary_tools: existingPrimaryTools.includes("submit_plan") + ? existingPrimaryTools + : [...existingPrimaryTools, "submit_plan"], + }; } opencodeConfig.agent ??= {}; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 34786573f..aa86c1b3b 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -491,13 +491,13 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-annotate", { description: "Open markdown file or folder in annotation UI", handler: async (args, ctx) => { - // Split --gate / --json from the path. --json is silently + // Split known annotate flags from the path. --json is silently // accepted (Pi writes back via sendUserMessage, not stdout). // `rawFilePath` keeps any leading `@` for the literal-@ fallback // (scoped-package-style names). - const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag } = parseAnnotateArgs(args ?? ""); + const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, noJina } = parseAnnotateArgs(args ?? ""); if (!filePath) { - ctx.ui.notify("Usage: /plannotator-annotate [--gate] [--json]", "error"); + ctx.ui.notify("Usage: /plannotator-annotate [--no-jina] [--gate] [--json]", "error"); return; } if (!hasPlanBrowserHtml()) { @@ -521,7 +521,7 @@ export default function plannotator(pi: ExtensionAPI): void { const isUrl = /^https?:\/\//i.test(filePath); if (isUrl) { - const useJina = resolveUseJina(false, loadConfig()); + const useJina = resolveUseJina(noJina, loadConfig()); ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info"); try { const result = await urlToMarkdown(filePath, { useJina }); diff --git a/packages/shared/annotate-args.test.ts b/packages/shared/annotate-args.test.ts index 5aae2f195..e1c3f633f 100644 --- a/packages/shared/annotate-args.test.ts +++ b/packages/shared/annotate-args.test.ts @@ -10,6 +10,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -21,6 +22,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -32,6 +34,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -43,6 +46,7 @@ describe("parseAnnotateArgs", () => { json: true, hook: false, renderHtml: false, + noJina: false, }); }); @@ -54,6 +58,7 @@ describe("parseAnnotateArgs", () => { json: true, hook: false, renderHtml: false, + noJina: false, }); }); @@ -65,6 +70,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -82,6 +88,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -93,6 +100,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -104,6 +112,7 @@ describe("parseAnnotateArgs", () => { json: true, hook: false, renderHtml: false, + noJina: false, }); }); @@ -115,6 +124,31 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, + }); + }); + + test("--no-jina is stripped from URL args", () => { + expect(parseAnnotateArgs("https://example.com/docs --no-jina --gate")).toEqual({ + filePath: "https://example.com/docs", + rawFilePath: "https://example.com/docs", + gate: true, + json: false, + hook: false, + renderHtml: false, + noJina: true, + }); + }); + + test("--no-jina before path is stripped", () => { + expect(parseAnnotateArgs("--no-jina https://example.com/docs")).toEqual({ + filePath: "https://example.com/docs", + rawFilePath: "https://example.com/docs", + gate: false, + json: false, + hook: false, + renderHtml: false, + noJina: true, }); }); @@ -126,6 +160,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -137,6 +172,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -148,6 +184,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -159,6 +196,7 @@ describe("parseAnnotateArgs", () => { json: true, hook: false, renderHtml: false, + noJina: false, }); }); @@ -176,6 +214,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -187,6 +226,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -198,6 +238,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -209,6 +250,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -225,6 +267,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -236,6 +279,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -247,6 +291,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -258,6 +303,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: false, + noJina: false, }); }); @@ -274,6 +320,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: true, renderHtml: false, + noJina: false, }); }); @@ -285,6 +332,7 @@ describe("parseAnnotateArgs", () => { json: true, hook: true, renderHtml: false, + noJina: false, }); }); @@ -296,6 +344,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: true, renderHtml: false, + noJina: false, }); }); @@ -307,6 +356,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: true, renderHtml: false, + noJina: false, }); }); @@ -318,6 +368,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: true, + noJina: false, }); }); @@ -329,6 +380,7 @@ describe("parseAnnotateArgs", () => { json: false, hook: false, renderHtml: true, + noJina: false, }); }); }); diff --git a/packages/shared/annotate-args.ts b/packages/shared/annotate-args.ts index cc1fe0287..40071f496 100644 --- a/packages/shared/annotate-args.ts +++ b/packages/shared/annotate-args.ts @@ -1,8 +1,8 @@ /** * Parse CLI-style args arriving as a single whitespace-delimited string. * - * Extracts the `--gate`, `--json`, and `--hook` flags from the remainder, - * which is treated as the target path. Leading `@` is + * Extracts known annotate flags from the remainder, which is treated as the + * target path. Leading `@` is * stripped via the shared at-reference helper — reference-mode is primary. * Scoped-package-style literal `@` paths are handled by a fallback that the * downstream resolver opts into (see at-reference.ts). @@ -44,6 +44,7 @@ export interface ParsedAnnotateArgs { json: boolean; hook: boolean; renderHtml: boolean; + noJina: boolean; } type Segment = { type: "ws" | "tok"; text: string }; @@ -53,11 +54,12 @@ const FLAG_MAP = { "--json": "json", "--hook": "hook", "--render-html": "renderHtml", + "--no-jina": "noJina", } as const satisfies Record>; export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs { const s = (raw ?? "").trim(); - const flags = { gate: false, json: false, hook: false, renderHtml: false }; + const flags = { gate: false, json: false, hook: false, renderHtml: false, noJina: false }; const segments: Segment[] = []; for (let i = 0; i < s.length;) { diff --git a/tests/manual/local/sandbox-opencode-isolated.sh b/tests/manual/local/sandbox-opencode-isolated.sh new file mode 100755 index 000000000..517fbbf80 --- /dev/null +++ b/tests/manual/local/sandbox-opencode-isolated.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# One-command isolated OpenCode sandbox for Plannotator local development. +# +# Usage: +# ./tests/manual/local/sandbox-opencode-isolated.sh [sandbox-opencode.sh options] +# +# Defaults: +# - isolated HOME/XDG/Bun cache dirs +# - forced Plannotator CLI bridge runtime +# - OpenCode sharing disabled +# +# Examples: +# ./tests/manual/local/sandbox-opencode-isolated.sh +# ./tests/manual/local/sandbox-opencode-isolated.sh --runtime auto +# ./tests/manual/local/sandbox-opencode-isolated.sh --runtime embedded --no-launch + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +exec "$SCRIPT_DIR/sandbox-opencode.sh" \ + --isolated \ + --runtime cli \ + --disable-sharing \ + "$@" diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index cf759ea7e..8676a701c 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -2,7 +2,7 @@ # Sandbox script for testing Plannotator OpenCode plugin locally # # Usage: -# ./sandbox-opencode.sh [--workflow MODE] [--planning-agents AGENTS] [--disable-sharing] [--keep] [--no-git] +# ./sandbox-opencode.sh [--isolated] [--runtime MODE] [--workflow MODE] [--planning-agents AGENTS] [--disable-sharing] [--keep] [--no-git] [--no-launch] # # Options: # --workflow MODE Plugin workflow to test: manual | plan-agent | all-agents @@ -11,8 +11,16 @@ # Default: plan # --disable-sharing Create opencode.json with "share": "disabled" to test # the sharing disable feature without env var pollution +# --isolated Run OpenCode with temporary HOME/XDG/Bun cache dirs +# so local plugin installs and command shims are ignored +# --isolation-root Directory to use for isolated HOME/XDG/Bun cache dirs +# --no-auth Don't copy the current OpenCode auth.json into isolation +# --runtime MODE Plugin runtime to force: auto | cli | embedded +# Default: auto # --keep Don't clean up sandbox on exit (for debugging) # --no-git Don't initialize git repo (tests non-git fallback) +# --no-launch Create the sandbox and config, then exit before OpenCode +# Implies --keep so the generated helpers remain usable # # What it does: # 1. Clears OpenCode-related caches @@ -40,8 +48,15 @@ PLUGIN_LOADER_RELATIVE_PATH="./.opencode/plannotator.ts" WORKFLOW="plan-agent" PLANNING_AGENTS="plan" DISABLE_SHARING=false +ISOLATED=false +ISOLATION_ROOT="" +COPY_AUTH=true +RUNTIME="auto" KEEP_SANDBOX=false NO_GIT=false +NO_LAUNCH=false +SOURCE_HOME="${HOME:-}" +SOURCE_XDG_DATA_HOME="${XDG_DATA_HOME:-}" while [ $# -gt 0 ]; do case "$1" in --workflow) @@ -64,6 +79,31 @@ while [ $# -gt 0 ]; do DISABLE_SHARING=true shift ;; + --isolated) + ISOLATED=true + shift + ;; + --isolation-root) + if [ -z "${2:-}" ]; then + echo "--isolation-root requires an argument" >&2 + exit 1 + fi + ISOLATION_ROOT="$2" + ISOLATED=true + shift 2 + ;; + --no-auth) + COPY_AUTH=false + shift + ;; + --runtime) + if [ -z "${2:-}" ]; then + echo "--runtime requires an argument" >&2 + exit 1 + fi + RUNTIME="$2" + shift 2 + ;; --keep) KEEP_SANDBOX=true shift @@ -72,6 +112,10 @@ while [ $# -gt 0 ]; do NO_GIT=true shift ;; + --no-launch) + NO_LAUNCH=true + shift + ;; *) echo "Unknown option: $1" >&2 exit 1 @@ -88,6 +132,54 @@ case "$WORKFLOW" in ;; esac +case "$RUNTIME" in + auto|cli|embedded) ;; + *) + echo "Invalid --runtime value: $RUNTIME" >&2 + echo "Expected one of: auto, cli, embedded" >&2 + exit 1 + ;; +esac + +if [ "$NO_LAUNCH" = true ]; then + KEEP_SANDBOX=true +fi + +if [ "$ISOLATED" = true ]; then + if [ -z "$ISOLATION_ROOT" ]; then + ISOLATION_ROOT=$(mktemp -d /tmp/plannotator-opencode-isolated-XXXXXX) + else + mkdir -p "$ISOLATION_ROOT" + fi + + mkdir -p \ + "$ISOLATION_ROOT/home" \ + "$ISOLATION_ROOT/config" \ + "$ISOLATION_ROOT/cache" \ + "$ISOLATION_ROOT/data/opencode" \ + "$ISOLATION_ROOT/state" \ + "$ISOLATION_ROOT/bun-cache" + + source_data_home="${SOURCE_XDG_DATA_HOME:-$SOURCE_HOME/.local/share}" + source_auth="$source_data_home/opencode/auth.json" + + export HOME="$ISOLATION_ROOT/home" + export XDG_CONFIG_HOME="$ISOLATION_ROOT/config" + export XDG_CACHE_HOME="$ISOLATION_ROOT/cache" + export XDG_DATA_HOME="$ISOLATION_ROOT/data" + export XDG_STATE_HOME="$ISOLATION_ROOT/state" + export BUN_INSTALL_CACHE_DIR="$ISOLATION_ROOT/bun-cache" + export PLANNOTATOR_BIN="${PLANNOTATOR_BIN:-$PROJECT_ROOT/bin/plannotator.js}" + + if [ "$COPY_AUTH" = true ]; then + if [ -f "$source_auth" ]; then + cp "$source_auth" "$XDG_DATA_HOME/opencode/auth.json" + else + echo "Warning: OpenCode auth not found at $source_auth; isolated OpenCode may need login." >&2 + fi + fi +fi + planning_agents_json() { local raw="$1" local IFS=',' @@ -109,6 +201,117 @@ planning_agents_json() { printf '[%s]' "${parts[*]}" } +shell_quote() { + printf "'%s'" "$(printf "%s" "$1" | sed "s/'/'\\\\''/g")" +} + +write_runtime_helpers() { + if [ "$ISOLATED" != true ]; then + return + fi + + local opencode_bin + opencode_bin="$(command -v opencode || true)" + local openchamber_repo="${OPENCHAMBER_REPO:-}" + local openchamber_data_dir="$ISOLATION_ROOT/openchamber-data" + mkdir -p "$openchamber_data_dir" + + cat > "$SANDBOX_DIR/plannotator-opencode-env.sh" << EOF +# Source this file to reuse the isolated OpenCode/Plannotator sandbox. +export PLANNOTATOR_OPENCODE_SANDBOX=$(shell_quote "$SANDBOX_DIR") +export PLANNOTATOR_OPENCODE_ISOLATION_ROOT=$(shell_quote "$ISOLATION_ROOT") +export HOME=$(shell_quote "$HOME") +export XDG_CONFIG_HOME=$(shell_quote "$XDG_CONFIG_HOME") +export XDG_CACHE_HOME=$(shell_quote "$XDG_CACHE_HOME") +export XDG_DATA_HOME=$(shell_quote "$XDG_DATA_HOME") +export XDG_STATE_HOME=$(shell_quote "$XDG_STATE_HOME") +export BUN_INSTALL_CACHE_DIR=$(shell_quote "$BUN_INSTALL_CACHE_DIR") +export PLANNOTATOR_BIN=$(shell_quote "$PLANNOTATOR_BIN") +export OPENCHAMBER_DATA_DIR=$(shell_quote "$openchamber_data_dir") +export OPENCHAMBER_REPO=$(shell_quote "$openchamber_repo") +EOF + + if [ -n "$opencode_bin" ]; then + cat >> "$SANDBOX_DIR/plannotator-opencode-env.sh" << EOF +export OPENCODE_BINARY=$(shell_quote "$opencode_bin") +export OPENCHAMBER_OPENCODE_PATH=$(shell_quote "$opencode_bin") +EOF + fi + + cat > "$SANDBOX_DIR/run-opencode.sh" << 'EOF' +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/plannotator-opencode-env.sh" +cd "$PLANNOTATOR_OPENCODE_SANDBOX" +exec "${OPENCODE_BINARY:-opencode}" "$@" +EOF + + cat > "$SANDBOX_DIR/run-opencode-serve.sh" << 'EOF' +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/plannotator-opencode-env.sh" +cd "$PLANNOTATOR_OPENCODE_SANDBOX" +PORT="${OPENCODE_PORT:-4097}" +exec "${OPENCODE_BINARY:-opencode}" serve --port "$PORT" "$@" +EOF + + cat > "$SANDBOX_DIR/run-openchamber.sh" << 'EOF' +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/plannotator-opencode-env.sh" +cd "$PLANNOTATOR_OPENCODE_SANDBOX" +export OPENCODE_PORT="${OPENCODE_PORT:-4097}" + +if [ -n "${OPENCHAMBER_CLI:-}" ]; then + exec "$OPENCHAMBER_CLI" serve --foreground "$@" +fi + +if command -v openchamber >/dev/null 2>&1; then + exec openchamber serve --foreground "$@" +fi + +if [ -n "${OPENCHAMBER_REPO:-}" ]; then + LOCAL_OPENCHAMBER_CLI="$OPENCHAMBER_REPO/packages/web/bin/cli.js" + LOCAL_OPENCHAMBER_DIST="$OPENCHAMBER_REPO/packages/web/dist/index.html" + + if [ -f "$LOCAL_OPENCHAMBER_CLI" ]; then + if [ ! -x "$OPENCHAMBER_REPO/node_modules/.bin/vite" ] && [ ! -x "$OPENCHAMBER_REPO/packages/web/node_modules/.bin/vite" ]; then + echo "Installing OpenChamber dependencies in $OPENCHAMBER_REPO..." + (cd "$OPENCHAMBER_REPO" && bun install) + fi + if [ ! -f "$LOCAL_OPENCHAMBER_DIST" ]; then + echo "Building OpenChamber web UI from $OPENCHAMBER_REPO..." + (cd "$OPENCHAMBER_REPO" && bun run build:web) + fi + exec node "$LOCAL_OPENCHAMBER_CLI" serve --foreground "$@" + fi +fi + +echo "Could not find OpenChamber." >&2 +echo "Install openchamber or set OPENCHAMBER_REPO / OPENCHAMBER_CLI before running this script." >&2 +exit 1 +EOF + + cat > "$SANDBOX_DIR/run-openchamber-external.sh" << 'EOF' +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/plannotator-opencode-env.sh" +export OPENCODE_PORT="${OPENCODE_PORT:-4097}" +export OPENCODE_SKIP_START=true +exec "$SCRIPT_DIR/run-openchamber.sh" "$@" +EOF + + chmod +x \ + "$SANDBOX_DIR/run-opencode.sh" \ + "$SANDBOX_DIR/run-opencode-serve.sh" \ + "$SANDBOX_DIR/run-openchamber.sh" \ + "$SANDBOX_DIR/run-openchamber-external.sh" +} + echo "=== Plannotator OpenCode Sandbox ===" echo "" @@ -140,6 +343,15 @@ cleanup() { rm -rf "$SANDBOX_DIR" echo "Done." fi + + if [ "$ISOLATED" = true ]; then + if [ "$KEEP_SANDBOX" = true ]; then + echo "Keeping isolation root at: $ISOLATION_ROOT" + echo "To clean up manually: rm -rf $ISOLATION_ROOT" + else + rm -rf "$ISOLATION_ROOT" + fi + fi } trap cleanup EXIT @@ -1626,6 +1838,7 @@ mkdir -p .opencode # The loader is referenced from opencode.json so we can pass plugin options. cat > .opencode/plannotator.ts << EOF // Loader for local Plannotator plugin development +export { default } from "$PLUGIN_DIR/index.ts"; export * from "$PLUGIN_DIR/index.ts"; EOF @@ -1634,8 +1847,9 @@ mkdir -p .opencode/commands cp "$PLUGIN_DIR/commands/"*.md .opencode/commands/ # Also install to global commands directory (some OpenCode versions need this) -mkdir -p ~/.config/opencode/commands -cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/commands/ 2>/dev/null || true +GLOBAL_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands" +mkdir -p "$GLOBAL_COMMANDS_DIR" +cp "$PLUGIN_DIR/commands/"*.md "$GLOBAL_COMMANDS_DIR/" 2>/dev/null || true echo "" @@ -1645,6 +1859,10 @@ PLUGIN_CONFIG=$(cat < opencode.json << EOF } EOF +write_runtime_helpers + echo "=== Sandbox Ready ===" echo "" echo "Directory: $SANDBOX_DIR" +if [ "$ISOLATED" = true ]; then + echo "Isolation root: $ISOLATION_ROOT" +fi echo "Workflow: $WORKFLOW" +echo "Runtime: $RUNTIME" if [ "$WORKFLOW" = "plan-agent" ]; then echo "Planning agents: $PLANNING_AGENTS" fi @@ -1682,6 +1906,14 @@ if [ "$DISABLE_SHARING" = true ]; then else echo "Sharing: enabled (default)" fi +if [ "$ISOLATED" = true ]; then + echo "" + echo "Reusable helpers:" + echo " OpenCode TUI: $SANDBOX_DIR/run-opencode.sh" + echo " OpenChamber managed: $SANDBOX_DIR/run-openchamber.sh" + echo " OpenCode server: $SANDBOX_DIR/run-opencode-serve.sh" + echo " OpenChamber external: $SANDBOX_DIR/run-openchamber-external.sh" +fi echo "" echo "To test:" case "$WORKFLOW" in @@ -1707,6 +1939,11 @@ echo " Type a draft in the browser, wait a few seconds, then close the tab w echo " /plannotator-annotate docs/folder-draft-b" echo " If the bug is present, folder B will show folder A's draft" echo "" +if [ "$NO_LAUNCH" = true ]; then + echo "Not launching OpenCode (--no-launch)." + exit 0 +fi + echo "Launching OpenCode..." echo ""