Skip to content
Open
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
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,16 @@ Claude Code: plannotator annotate subcommand runs
OpenCode/Pi: event handler intercepts command
Input type detected:
.md/.mdx → file read from disk
.html/.htm → file read, rendered as raw HTML by default (or converted to markdown with --markdown)
https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
folder/ → file browser opened, files converted on demand
.md/.mdx → file read from disk
.html/.htm → file read, rendered as raw HTML by default (or converted to markdown with --markdown)
http://localhost, 127.*, [::1] → LIVE design preview: a per-session reverse proxy
(packages/server/preview-proxy.ts) forwards to the dev server, proxies its
HMR websocket, and injects the annotation bridge; the editor renders an
<iframe src> at the proxy origin so the real page (Vite ES-module graph and
all) loads and is annotated as HTML. Loopback-only. Non-goals: URL sharing,
version history, and host-theme token injection are disabled for live sessions.
https:// (remote)→ fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
folder/ → file browser opened, files converted on demand
Annotate server starts (reuses plan editor HTML with mode:"annotate")
Expand Down Expand Up @@ -359,7 +365,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan?, versionInfo?, diffCurrent?, diffHtml? }`. The last four power the per-file version diff: `previousPlan`/`versionInfo`/`diffCurrent` for the markdown diff, `diffHtml` (the previous→current page rendered with inline `<ins>`/`<del>`) for `--render-html` files. |
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, livePreviewUrl?, previousPlan?, versionInfo?, diffCurrent?, diffHtml? }`. `livePreviewUrl` (with `renderAs: "html"`) is set for live localhost design previews — the editor renders an `<iframe src>` at that per-session reverse-proxy origin instead of markdown/raw HTML. The last four power the per-file version diff: `previousPlan`/`versionInfo`/`diffCurrent` for the markdown diff, `diffHtml` (the previous→current page rendered with inline `<ins>`/`<del>`) for `--render-html` files. |
| `/api/plan/version` | GET | Fetch a specific stored version of the annotated file (`?v=N`) |
| `/api/plan/versions` | GET | List all stored versions of the annotated file |
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
Expand Down
27 changes: 25 additions & 2 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ import {
} from "@plannotator/shared/goal-setup";
import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { urlToMarkdown, isConvertedSource, isLoopbackUrl } from "@plannotator/shared/url-to-markdown";
import { createWorktreePool, type WorktreePool, type PoolEntry } from "@plannotator/shared/worktree-pool";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
Expand Down Expand Up @@ -918,11 +918,33 @@ if (args[0] === "sessions") {
let annotateMode: "annotate" | "annotate-folder" = "annotate";
let sourceInfo: string | undefined;
let sourceConverted = false;
let livePreviewUrl: string | undefined;
let previewProxy: { origin: string; stop: () => void } | undefined;

// --- URL annotation ---
const isUrl = /^https?:\/\//i.test(filePath);

if (isUrl) {
if (isUrl && isLoopbackUrl(filePath)) {
// Live design-preview: proxy the dev server so a real-origin iframe can
// render its module graph, and inject the annotation bridge.
const { startPreviewProxy } = await import("@plannotator/server/preview-proxy");
console.error(`Live preview: proxying ${filePath}`);
try {
previewProxy = await startPreviewProxy(filePath);
} catch (err) {
console.error(`Failed to start live preview proxy: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
// The proxy owns the origin root and forwards every path; carry the target's
// path+query onto the proxy origin so the iframe loads the requested page,
// not the dev server's default route.
const targetUrl = new URL(filePath);
livePreviewUrl = previewProxy.origin + targetUrl.pathname + targetUrl.search;
process.on("exit", () => previewProxy?.stop());
markdown = "";
absolutePath = filePath;
sourceInfo = filePath;
} else if (isUrl) {
const useJina = resolveUseJina(cliNoJina, loadConfig());
console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`);
try {
Expand Down Expand Up @@ -1036,6 +1058,7 @@ if (args[0] === "sessions") {
gate: gateFlag,
rawHtml,
renderHtml: !!rawHtml,
livePreviewUrl,
convertHtml: renderMarkdownFlag,
agentCwd: projectRoot,
project: annotateProject,
Expand Down
27 changes: 25 additions & 2 deletions apps/opencode-plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { urlToMarkdown, isConvertedSource, isLoopbackUrl } from "@plannotator/shared/url-to-markdown";
import { startPreviewProxy } from "@plannotator/server/preview-proxy";
import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace";
import { statSync } from "fs";
import path from "path";
Expand Down Expand Up @@ -224,12 +225,32 @@ export async function handleAnnotateCommand(
let isFolder = false;
let sourceInfo: string | undefined;
let sourceConverted = false;
let livePreviewUrl: string | undefined;
let previewProxy: { origin: string; stop: () => void } | undefined;
const agentCwd = directory || process.cwd();

// --- URL annotation ---
const isUrl = /^https?:\/\//i.test(filePath);

if (isUrl) {
if (isUrl && isLoopbackUrl(filePath)) {
// Live design-preview: proxy the dev server so a real-origin iframe can
// render its module graph, and inject the annotation bridge.
client.app.log({ level: "info", message: `Live preview: proxying ${filePath}...` });
try {
previewProxy = await startPreviewProxy(filePath);
} catch (err) {
client.app.log({ level: "error", message: `Failed to start live preview proxy: ${err instanceof Error ? err.message : String(err)}` });
return;
}
// The proxy owns the origin root and forwards every path; carry the
// target's path+query onto the proxy origin so the iframe loads the
// requested page, not the dev server's default route.
const targetUrl = new URL(filePath);
livePreviewUrl = previewProxy.origin + targetUrl.pathname + targetUrl.search;
markdown = "";
absolutePath = filePath;
sourceInfo = filePath;
} else if (isUrl) {
const useJina = resolveUseJina(noJina, loadConfig());
client.app.log({ level: "info", message: `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...` });
try {
Expand Down Expand Up @@ -323,6 +344,7 @@ export async function handleAnnotateCommand(
sourceConverted,
rawHtml,
renderHtml: !!rawHtml,
livePreviewUrl,
convertHtml: renderMarkdownFlag,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
Expand All @@ -339,6 +361,7 @@ export async function handleAnnotateCommand(
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
previewProxy?.stop();

// Both exit and approve are "no-op for the agent" — skip session injection.
if (result.exit || result.approved) {
Expand Down
33 changes: 31 additions & 2 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import {
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
import { urlToMarkdown, isConvertedSource, isLoopbackUrl } from "./generated/url-to-markdown.js";
import { startPreviewProxy } from "./server/previewProxy.js";
import { loadConfig, resolveUseJina } from "./generated/config.js";
import { readImprovementHook } from "./generated/improvement-hooks.js";
import { composeImproveContext } from "./generated/pfm-reminder.js";
Expand Down Expand Up @@ -521,11 +522,31 @@ export default function plannotator(pi: ExtensionAPI): void {
let sourceInfo: string | undefined;
let sourceConverted = false;
let isFolder = false;
let livePreviewUrl: string | undefined;
let previewProxy: { origin: string; stop: () => void } | undefined;

// --- URL annotation ---
const isUrl = /^https?:\/\//i.test(filePath);

if (isUrl) {
if (isUrl && isLoopbackUrl(filePath)) {
// Live design-preview: proxy the dev server so a real-origin iframe can
// render its module graph, and inject the annotation bridge.
ctx.ui.notify(`Live preview: proxying ${filePath}...`, "info");
try {
previewProxy = await startPreviewProxy(filePath);
} catch (err) {
ctx.ui.notify(`Failed to start live preview proxy: ${err instanceof Error ? err.message : String(err)}`, "error");
return;
}
// The proxy owns the origin root and forwards every path; carry the
// target's path+query onto the proxy origin so the iframe loads the
// requested page, not the dev server's default route.
const targetUrl = new URL(filePath);
livePreviewUrl = previewProxy.origin + targetUrl.pathname + targetUrl.search;
markdown = "";
absolutePath = filePath;
sourceInfo = filePath;
} else if (isUrl) {
const useJina = resolveUseJina(noJina, loadConfig());
ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
try {
Expand Down Expand Up @@ -608,6 +629,8 @@ export default function plannotator(pi: ExtensionAPI): void {
rawHtml,
!!rawHtml,
renderMarkdownFlag,
undefined,
livePreviewUrl,
);
ctx.ui.notify(sessionOpenedMessage("Annotation opened", session.url), "info");
void session
Expand Down Expand Up @@ -643,8 +666,14 @@ export default function plannotator(pi: ExtensionAPI): void {
})
.catch((err) => {
reportBackgroundError(ctx, "Plannotator annotation session failed", err, origin);
})
.finally(() => {
// Pi is a persistent extension host, so the live-preview proxy
// must die with the session (not on process exit).
try { previewProxy?.stop(); } catch {}
});
} catch (err) {
try { previewProxy?.stop(); } catch {}
ctx.ui.notify(
`Failed to start annotation UI: ${getStartupErrorMessage(err)}`,
"error",
Expand Down
2 changes: 2 additions & 0 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ export async function startMarkdownAnnotationSession(
renderHtml?: boolean,
convertHtml?: boolean,
recentMessages?: { messageId: string; text: string; timestamp?: string }[],
livePreviewUrl?: string,
): Promise<BrowserDecisionSession<{ feedback: string; exit?: boolean; approved?: boolean; selectedMessageId?: string; feedbackScope?: "message" | "messages" }>> {
if (!ctx.hasUI || !planHtmlContent) {
throw new Error("Plannotator annotation browser is unavailable in this session.");
Expand Down Expand Up @@ -556,6 +557,7 @@ export async function startMarkdownAnnotationSession(
rawHtml,
renderHtml,
convertHtml,
livePreviewUrl,
htmlContent: planHtmlContent,
sharingEnabled: resolveSharingEnabled(loadConfig()),
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
Expand Down
Loading