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
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ Input type detected:
deliberately excluded — it commonly holds secrets and annotate history
copies file contents; source-code extensions stay with code review)
.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:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
folder/ → file browser opened, files converted on demand
Expand Down Expand Up @@ -372,7 +378,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 @@ -925,11 +925,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 @@ -1047,6 +1069,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 @@ -227,12 +228,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 @@ -330,6 +351,7 @@ export async function handleAnnotateCommand(
sourceConverted,
rawHtml,
renderHtml: !!rawHtml,
livePreviewUrl,
convertHtml: renderMarkdownFlag,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
Expand All @@ -346,6 +368,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
58 changes: 46 additions & 12 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,24 +551,50 @@ 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) {
const useJina = resolveUseJina(noJina, loadConfig());
ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
try {
const { isConvertedSource, urlToMarkdown } = await import("./generated/url-to-markdown.js");
const result = await urlToMarkdown(filePath, { useJina });
markdown = result.markdown;
sourceConverted = isConvertedSource(result.source);
} catch (err) {
ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
return;
// Lazy-load the URL helper (keeps the heavy Turndown graph off startup,
// matching this file's lazy-import discipline).
const { isLoopbackUrl } = await import("./generated/url-to-markdown.js");
if (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("./server/previewProxy.js");
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 {
const useJina = resolveUseJina(noJina, loadConfig());
ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info");
try {
const { isConvertedSource, urlToMarkdown } = await import("./generated/url-to-markdown.js");
const result = await urlToMarkdown(filePath, { useJina });
markdown = result.markdown;
sourceConverted = isConvertedSource(result.source);
} catch (err) {
ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
return;
}
absolutePath = filePath;
sourceInfo = filePath;
}
absolutePath = filePath;
sourceInfo = filePath;
} else {
// Pick the interpretation of the user input that actually exists:
// stripped form first (reference-mode primary), literal as fallback
Expand Down Expand Up @@ -644,6 +670,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 @@ -680,8 +708,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 @@ -523,6 +523,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) {
throw new Error("Plannotator annotation browser is unavailable in this session.");
Expand Down Expand Up @@ -557,6 +558,7 @@ export async function startMarkdownAnnotationSession(
rawHtml,
renderHtml,
convertHtml,
livePreviewUrl,
htmlContent: planHtmlContent,
sharingEnabled: resolveSharingEnabled(loadConfig()),
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
Expand Down
Loading