diff --git a/apps/opencode-plugin/cli-bridge.test.ts b/apps/opencode-plugin/cli-bridge.test.ts index 50188e3aa..7ea2f6236 100644 --- a/apps/opencode-plugin/cli-bridge.test.ts +++ b/apps/opencode-plugin/cli-bridge.test.ts @@ -7,8 +7,12 @@ import { buildCliBridgeEnv, buildCliSpawnConfig, buildReviewPromptFromBridgeOutcome, + formatDetachedPlanReviewMessage, formatUserFacingCliStderrLine, + getDetachedPlanReviewUrl, getRecentAssistantMessages, + isOpenChamberRuntime, + resolveDetachedPlanReviewPort, } from "./cli-bridge"; import { getReviewDeniedSuffix } from "@plannotator/shared/prompts"; @@ -84,6 +88,25 @@ describe("OpenCode CLI bridge helpers", () => { expect(formatUserFacingCliStderrLine("Fetching: https://example.com")).toBeUndefined(); }); + test("detects OpenChamber runtime without recursing in the detached child", () => { + expect(isOpenChamberRuntime({ OPENCHAMBER_RUNTIME: "desktop" } as NodeJS.ProcessEnv)).toBe(true); + expect(isOpenChamberRuntime({ OPENCHAMBER_RUNTIME: "desktop", PLANNOTATOR_DETACHED_CHILD: "1" } as NodeJS.ProcessEnv)).toBe(false); + expect(isOpenChamberRuntime({} as NodeJS.ProcessEnv)).toBe(false); + }); + + test("chooses a concrete detached review port for immediate OpenChamber URLs", () => { + expect(resolveDetachedPlanReviewPort({ PLANNOTATOR_PORT: "4432" } as NodeJS.ProcessEnv)).toBe(4432); + expect(resolveDetachedPlanReviewPort({ PLANNOTATOR_PORT: "0" } as NodeJS.ProcessEnv, () => 0)).toBe(19_000); + expect(resolveDetachedPlanReviewPort({ PLANNOTATOR_PORT: "bad" } as NodeJS.ProcessEnv, () => 0.5)).toBe(29_000); + }); + + test("formats the detached OpenChamber review URL message", () => { + const url = getDetachedPlanReviewUrl(4432); + expect(url).toBe("http://localhost:4432"); + expect(formatDetachedPlanReviewMessage(url)).toContain("Review UI is starting at http://localhost:4432"); + expect(formatDetachedPlanReviewMessage(url)).toContain("approval or comments"); + }); + test("resolves Windows CLI commands to an executable without shell mode", () => { const dir = mkdtempSync(path.join(tmpdir(), "plannotator-cli-")); try { diff --git a/apps/opencode-plugin/cli-bridge.ts b/apps/opencode-plugin/cli-bridge.ts index 00dbc8316..16af041d4 100644 --- a/apps/opencode-plugin/cli-bridge.ts +++ b/apps/opencode-plugin/cli-bridge.ts @@ -60,6 +60,12 @@ interface RunCliResult { exitCode: number | null; } +export interface DetachedPlanReviewLaunch { + url: string; + port: number; + message: string; +} + interface CliSpawnConfig { command: string; args: string[]; @@ -245,6 +251,127 @@ function logReadyFile(client: OpenCodeClient, readyFile: string, readyLabel: str } } +export function isOpenChamberRuntime(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.OPENCHAMBER_RUNTIME?.trim()) && env.PLANNOTATOR_DETACHED_CHILD !== "1"; +} + +export function resolveDetachedPlanReviewPort( + env: NodeJS.ProcessEnv = process.env, + random: () => number = Math.random, +): number { + const rawPort = env.PLANNOTATOR_PORT?.trim(); + if (rawPort) { + const parsed = Number.parseInt(rawPort, 10); + if (Number.isFinite(parsed) && parsed > 0 && parsed < 65_536) { + return parsed; + } + } + + return 19_000 + Math.floor(random() * 20_000); +} + +export function getDetachedPlanReviewUrl(port: number): string { + return `http://localhost:${port}`; +} + +export function formatDetachedPlanReviewMessage(url: string): string { + return `[Plannotator] Review UI is starting at ${url} + +Open this URL in your browser to review the plan. Do not continue implementation yet; Plannotator will send your approval or comments back into this session when you submit the review.`; +} + +export function startDetachedCliPlanReview(input: { + client: OpenCodeClient; + planContent: string; + cwd?: string; + timeoutSeconds: number | null; + bridge?: OpenCodeBridgeContext; + onResult?: (result: OpenCodePlanReviewResult) => void | Promise; + onError?: (error: Error) => void | Promise; +}): DetachedPlanReviewLaunch { + const port = resolveDetachedPlanReviewPort(); + const url = getDetachedPlanReviewUrl(port); + const cwd = input.cwd || process.cwd(); + const payload = JSON.stringify({ + plan: input.planContent, + timeoutSeconds: input.timeoutSeconds, + ...buildBridgePayload(input.bridge), + }); + const env = { + ...process.env, + ...buildCliBridgeEnv(input.bridge), + OPENCHAMBER_RUNTIME: "", + PLANNOTATOR_DETACHED_CHILD: "1", + PLANNOTATOR_SKIP_BROWSER_OPEN: "1", + PLANNOTATOR_PORT: String(port), + OPENCODE: "1", + PLANNOTATOR_ORIGIN: "opencode", + PLANNOTATOR_CWD: cwd, + }; + + const notifyError = (error: Error) => { + log(input.client, "error", `[Plannotator] Detached plan review failed: ${error.message}`); + void input.onError?.(error); + }; + + const bin = getPlannotatorBin(); + const spawnConfig = buildCliSpawnConfig(bin, ["opencode-plan"]); + const child = spawn(spawnConfig.command, spawnConfig.args, { + cwd, + env, + shell: spawnConfig.shell, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Failed to open pipes for the detached plannotator CLI process."); + } + + let stdout = ""; + let stderr = ""; + const stderrForwarder = createCliStderrForwarder(input.client); + + 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) => { + const hint = error.code === "ENOENT" + ? "Could not find the plannotator CLI. Install it with: curl -fsSL https://plannotator.ai/install.sh | bash" + : error.message; + notifyError(new Error(hint)); + }); + child.stdin.on("error", (error: Error) => { + notifyError(new Error(`Failed to write detached plan payload: ${error.message}`)); + }); + child.on("close", (exitCode) => { + stderrForwarder.flush(); + if (exitCode !== 0) { + notifyError(new Error(stderr.trim() || `Plannotator CLI exited with code ${exitCode}`)); + return; + } + + try { + logCliWarnings(input.client, stderr); + const result = parseLastJson(stdout); + void input.onResult?.(result); + } catch (error) { + notifyError(error instanceof Error ? error : new Error(String(error))); + } + }); + + child.stdin.end(payload); + log(input.client, "info", `[Plannotator] Starting detached plan review: ${url}`); + + return { url, port, message: formatDetachedPlanReviewMessage(url) }; +} + async function runPlannotatorCli(options: RunCliOptions): Promise { const readyFile = path.join( tmpdir(), diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 3c9ee45c5..c2071dbc7 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -55,7 +55,9 @@ import { } from "./plan-edits"; import { handleCliCommand, + isOpenChamberRuntime, runCliPlanReview, + startDetachedCliPlanReview, type OpenCodeBridgeContext, type OpenCodePlanReviewResult, } from "./cli-bridge"; @@ -635,6 +637,90 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo writeFileSync(backingPath, planContent, "utf-8"); const timeoutSeconds = getPlanTimeoutSeconds(); + if (isOpenChamberRuntime()) { + const sendDetachedResult = async (result: OpenCodePlanReviewResult) => { + try { + if (result.approved) { + try { unlinkSync(backingPath); } catch { /* already gone */ } + + const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; + const targetAgent = result.agentSwitch || 'build'; + const text = result.feedback + ? getPlanApprovedWithNotesPrompt("opencode", undefined, { + planFilePath: backingPath, + doneMsg: result.savedPath ? `Saved to: ${result.savedPath}` : "", + feedback: result.feedback, + proceedSuffix: shouldSwitchAgent + ? "\n\nProceed with implementation, incorporating these notes where applicable." + : "", + }) + : getPlanApprovedPrompt("opencode", undefined, { + planFilePath: backingPath, + doneMsg: result.savedPath ? ` Saved to: ${result.savedPath}` : "", + }); + + await ctx.client.session.prompt({ + path: { id: context.sessionID }, + body: { + ...(shouldSwitchAgent && { agent: targetAgent }), + parts: [{ type: "text", text }], + }, + }); + return; + } + + const lineNumberedPlan = formatWithLineNumbers(planContent); + const totalLines = planContent.split("\n").length; + const text = getPlanDeniedPrompt("opencode", undefined, { + toolName: getPlanToolName("opencode"), + planFileRule: "", + feedback: result.feedback || "Plan changes requested", + }) + `\n\n## Current Plan (${totalLines} lines)\n\nThe plan below shows the current state with line numbers. Use these exact line numbers in your next \`submit_plan\` call:\n\n\`\`\`\n${lineNumberedPlan}\n\`\`\`\n\nCall \`submit_plan\` with targeted edits to address the feedback above.`; + + await ctx.client.session.prompt({ + path: { id: context.sessionID }, + body: { parts: [{ type: "text", text }] }, + }); + } catch (error) { + try { + void ctx.client.app.log({ + level: "error", + message: `[Plannotator] Failed to deliver detached plan result: ${error instanceof Error ? error.message : String(error)}`, + }); + } catch {} + } + }; + + const sendDetachedError = async (error: Error) => { + try { + await ctx.client.session.prompt({ + path: { id: context.sessionID }, + body: { + parts: [{ + type: "text", + text: `[Plannotator] Failed to complete detached plan review: ${error.message}`, + }], + }, + }); + } catch {} + }; + + try { + const launch = startDetachedCliPlanReview({ + client: ctx.client, + planContent, + timeoutSeconds, + cwd: ctx.directory, + bridge: await getBridgeContext(), + onResult: sendDetachedResult, + onError: sendDetachedError, + }); + return launch.message; + } catch (error) { + return `[Plannotator] Failed to start detached plan review: ${error instanceof Error ? error.message : String(error)}`; + } + } + let result: OpenCodePlanReviewResult; try { result = await runPlanReview({