Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/opencode-plugin/cli-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
buildCliBridgeEnv,
buildCliSpawnConfig,
buildReviewPromptFromBridgeOutcome,
formatDetachedPlanReviewMessage,
formatUserFacingCliStderrLine,
getDetachedPlanReviewUrl,
getRecentAssistantMessages,
isOpenChamberRuntime,
resolveDetachedPlanReviewPort,
} from "./cli-bridge";
import { getReviewDeniedSuffix } from "@plannotator/shared/prompts";

Expand Down Expand Up @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions apps/opencode-plugin/cli-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ interface RunCliResult {
exitCode: number | null;
}

export interface DetachedPlanReviewLaunch {
url: string;
port: number;
message: string;
}

interface CliSpawnConfig {
command: string;
args: string[];
Expand Down Expand Up @@ -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<void>;
onError?: (error: Error) => void | Promise<void>;
}): 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<OpenCodePlanReviewResult>(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<RunCliResult> {
const readyFile = path.join(
tmpdir(),
Expand Down
86 changes: 86 additions & 0 deletions apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ import {
} from "./plan-edits";
import {
handleCliCommand,
isOpenChamberRuntime,
runCliPlanReview,
startDetachedCliPlanReview,
type OpenCodeBridgeContext,
type OpenCodePlanReviewResult,
} from "./cli-bridge";
Expand Down Expand Up @@ -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({
Expand Down