/** * plan-html — Global pi extension. * * Workflow: /plan → model writes a structured plan-json block * → extension renders it as a commentable HTML page via the * plan-design-system, opens it in the default browser, and * keeps tools locked to read-only. * * /iterate → loads .pi/plans/feedback.md, asks the * model to revise; new version is rendered & opened. * * /approve → unlocks edit/write, asks the model * to execute the latest plan; [DONE:n] markers tick a footer * progress widget. * * /plan-off → cancel planning, restore full tools. * * Model-agnostic: relies only on the assistant emitting a fenced * ```plan-json``` block. Works on Claude, OpenAI, Gemini, etc. * * Design system: ~/.pi/agent/plan-design-system/ */ import { execFile } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { createServer, type Server } from "node:http"; import { platform } from "node:os"; import { dirname, join, relative, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { randomBytes } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Key } from "@earendil-works/pi-tui"; // ============================================================ // Constants // ============================================================ // Design system ships inside this package so the extension is self-contained. // Resolves to /design-system regardless of where npm installs us. const DESIGN_SYSTEM_DIR = join(dirname(fileURLToPath(import.meta.url)), "design-system"); const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"]; const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"]; const FEEDBACK_FILENAME = "feedback.md"; const STATE_ENTRY = "plan-html-state"; type Phase = "idle" | "planning" | "executing"; type MockupFrame = "window" | "browser" | "phone" | "none"; type MockupViewport = "desktop" | "mobile" | "auto"; interface MockupObject { frame?: MockupFrame; title?: string; viewport?: MockupViewport; html: string; css?: string; } interface PlanStep { key: string; title: string; body?: string; files?: string[]; callouts?: Array<{ tone?: string; title?: string; body: string }>; code?: Array<{ lang?: string; label?: string; source: string }>; mockup?: string | MockupObject; diagram?: string; verification?: string; } interface PlanDoc { title: string; context: string; header_chips?: Array<{ label: string; tone?: string }>; steps: PlanStep[]; risks?: string[]; open_questions?: string[]; } interface SessionState { phase: Phase; planBase: string | null; // e.g. "plan-2026-05-20T14-23-15" version: number; // 1, 2, … executionTodos: Array<{ step: number; text: string; completed: boolean }>; } // ============================================================ // Safe bash patterns (subset of bundled plan-mode example) // ============================================================ const DESTRUCTIVE_PATTERNS: RegExp[] = [ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /(^|[^<])>(?!>)/, />>/, /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, /\byarn\s+(add|remove|install|publish)/i, /\bpnpm\s+(add|remove|install|publish)/i, /\bpip\s+(install|uninstall)/i, /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i, /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, /\breboot\b/i, /\bshutdown\b/i, ]; const SAFE_PATTERNS: RegExp[] = [ /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/, /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/, /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, /^\s*date\b/, /^\s*uptime\b/, /^\s*ps\b/, /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-)/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, /^\s*node\s+--version/i, /^\s*python3?\s+--version/i, /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/, /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, ]; function isSafeBash(command: string): boolean { const destructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command)); const safe = SAFE_PATTERNS.some((p) => p.test(command)); return !destructive && safe; } // ============================================================ // String / HTML helpers // ============================================================ function escHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escAttr(s: string): string { return escHtml(s); } function slugify(s: string): string { return s .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) || "section"; } function timestampSlug(d = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return ( `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T` + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}` ); } function isAssistant(m: AgentMessage): m is AssistantMessage { return m.role === "assistant" && Array.isArray(m.content); } function assistantText(m: AssistantMessage): string { return m.content .filter((b): b is TextContent => b.type === "text") .map((b) => b.text) .join("\n"); } // ============================================================ // Plan-JSON extraction // ============================================================ const PLAN_FENCE_RE = /```(?:plan-json|json)\s*\n([\s\S]*?)\n```/i; function extractPlanJson(text: string): { plan: PlanDoc | null; rawProse: string } { const m = text.match(PLAN_FENCE_RE); if (!m) return { plan: null, rawProse: text }; const before = text.slice(0, m.index ?? 0).trim(); try { const data = JSON.parse(m[1]) as PlanDoc; // Backfill required fields & step keys if (typeof data.title !== "string") data.title = "Plan"; if (typeof data.context !== "string") data.context = ""; if (!Array.isArray(data.steps)) data.steps = []; data.steps.forEach((s, i) => { if (typeof s.title !== "string" || !s.title) s.title = `Step ${i + 1}`; if (typeof s.key !== "string" || !s.key) s.key = slugify(s.title); }); // Ensure unique keys const seen = new Set(); data.steps.forEach((s) => { let k = s.key; let n = 2; while (seen.has(k)) k = `${s.key}-${n++}`; s.key = k; seen.add(k); }); return { plan: data, rawProse: before }; } catch { return { plan: null, rawProse: text }; } } // ============================================================ // Tiny markdown renderer // Supports: headings, paragraphs, lists, blockquotes, fenced code, // tables, inline code/strong/em/links, line breaks. // ============================================================ function renderMarkdown(src: string): string { if (!src) return ""; const lines = src.replace(/\r\n/g, "\n").split("\n"); const out: string[] = []; let i = 0; function inline(s: string): string { // Escape first, then re-introduce inline markup. let t = escHtml(s); // Code (must come before * / _) t = t.replace(/`([^`]+)`/g, "$1"); // Bold t = t.replace(/\*\*([^*]+)\*\*/g, "$1"); t = t.replace(/__([^_]+)__/g, "$1"); // Italic (basic) t = t.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2"); t = t.replace(/(^|[\s(])_([^_\n]+)_/g, "$1$2"); // Links t = t.replace( /\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => `${label}`, ); return t; } while (i < lines.length) { const line = lines[i]; if (/^\s*$/.test(line)) { i++; continue; } // Headings const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { const level = Math.min(h[1].length, 4) + 2; // h1→h3, h2→h4, … out.push(`${inline(h[2])}`); i++; continue; } // Fenced code const fence = line.match(/^```(\w+)?\s*$/); if (fence) { const lang = fence[1] || ""; const code: string[] = []; i++; while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i++; } i++; // consume closing fence out.push(renderCodeBlock({ lang, source: code.join("\n") })); continue; } // Tables (very simple: pipe-delimited with header sep row) if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*[-:]+\s*\|/.test(lines[i + 1] || "")) { const headers = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); i += 2; const rows: string[][] = []; while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push( lines[i].trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()), ); i++; } out.push(""); headers.forEach((h) => out.push(``)); out.push(""); rows.forEach((r) => { out.push(""); r.forEach((c) => out.push(``)); out.push(""); }); out.push("
${inline(h)}
${inline(c)}
"); continue; } // Blockquote if (/^\s*>/.test(line)) { const buf: string[] = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i++; } out.push(`
${inline(buf.join(" "))}
`); continue; } // Unordered list if (/^\s*[-*+]\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, "")); i++; } out.push("
    "); items.forEach((t) => out.push(`
  • ${inline(t)}
  • `)); out.push("
"); continue; } // Ordered list if (/^\s*\d+[.)]\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")); i++; } out.push("
    "); items.forEach((t) => out.push(`
  1. ${inline(t)}
  2. `)); out.push("
"); continue; } // Paragraph (collapse subsequent non-empty, non-block lines) const buf: string[] = [line]; i++; while ( i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*>|\s*\|)/.test(lines[i]) ) { buf.push(lines[i]); i++; } out.push(`

${inline(buf.join(" "))}

`); } return out.join("\n"); } // ============================================================ // Component renderers // ============================================================ function renderChip(label: string, tone?: string): string { const toneClass = tone ? ` pds-chip--${escAttr(tone)}` : ""; return `${escHtml(label)}`; } function renderHeaderChips(chips?: Array<{ label: string; tone?: string }>): string { if (!chips || !chips.length) return ""; return chips.map((c) => renderChip(c.label, c.tone)).join(""); } function renderFiles(files?: string[]): string { if (!files || !files.length) return ""; return `
${files .map((f) => `${escHtml(f)}`) .join("")}
`; } const CALLOUT_ICONS: Record = { note: "i", warn: "!", risk: "!", danger: "!", ok: "✓", success: "✓", }; function renderCallout(c: { tone?: string; title?: string; body: string }): string { const tone = (c.tone || "note").toLowerCase(); const icon = CALLOUT_ICONS[tone] || "•"; const title = c.title ? `${escHtml(c.title)} ` : ""; return ``; } function renderCodeBlock(c: { lang?: string; label?: string; source: string }): string { const lang = (c.lang || "").toLowerCase(); const langClass = lang ? ` class="language-${escAttr(lang)}"` : ""; const label = c.label ? escHtml(c.label) : (lang || "code"); return `
${label}
${escHtml(c.source)}
`; } // --- Mockup preset cache --- let MOCKUP_PRESET_CACHE: string | null = null; function loadMockupPreset(): string { if (MOCKUP_PRESET_CACHE != null) return MOCKUP_PRESET_CACHE; const p = join(DESIGN_SYSTEM_DIR, "vendor", "mockup-preset.css"); try { MOCKUP_PRESET_CACHE = readFileSync(p, "utf8"); } catch { MOCKUP_PRESET_CACHE = ""; } return MOCKUP_PRESET_CACHE; } // Encode for a double-quoted srcdoc attribute. Leaves `<`/`>` alone so // the iframe parses the contained HTML normally. function escSrcdoc(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """); } function stripScripts(html: string): string { return html.replace(/]*>[\s\S]*?<\/script\s*>/gi, ""); } let MOCK_ID_COUNTER = 0; function nextMockId(): string { MOCK_ID_COUNTER += 1; return `mk${Date.now().toString(36)}${MOCK_ID_COUNTER.toString(36)}`; } const MOCK_RESIZE_SCRIPT = ` (function(){ var id = ${JSON.stringify("__MOCK_ID__")}; function h(){ return document.documentElement.scrollHeight || document.body.scrollHeight || 0; } function post(){ try { parent.postMessage({type:"mock-height", id:id, h:h()}, "*"); } catch(e){} } window.addEventListener("load", post); window.addEventListener("resize", post); if (window.ResizeObserver) { try { new ResizeObserver(post).observe(document.body); } catch(e){} } setTimeout(post, 50); setTimeout(post, 250); setTimeout(post, 800); window.addEventListener("message", function(e){ if (e && e.data && e.data.type === "mock-remeasure") post(); }); })();`; function buildMockSrcdoc(mock: MockupObject, mockId: string): string { const preset = loadMockupPreset(); const userCss = (mock.css || "").trim(); const userHtml = stripScripts(mock.html || ""); const script = MOCK_RESIZE_SCRIPT.replace("__MOCK_ID__", mockId); return ` ${userCss ? `` : ""} ${userHtml} `; } function renderMockupString(m: string): string { return `
Mockup
${escHtml(m)}
`; } function renderMockupObject(m: MockupObject): string { const frame: MockupFrame = (m.frame as MockupFrame) || "window"; const validFrames: MockupFrame[] = ["window", "browser", "phone", "none"]; const safeFrame = validFrames.includes(frame) ? frame : "window"; const requested: MockupViewport = (m.viewport as MockupViewport) || "auto"; const viewport: MockupViewport = requested === "auto" ? (safeFrame === "phone" ? "mobile" : "desktop") : requested; const title = m.title || ""; const mockId = nextMockId(); const srcdoc = buildMockSrcdoc(m, mockId); let chrome: string; if (safeFrame === "browser") { chrome = `
${escHtml(title || "preview")}
`; } else if (safeFrame === "window") { chrome = `
${escHtml(title)}
`; } else if (safeFrame === "phone") { chrome = `
`; } else { chrome = ""; } return `
Mockup${title ? ` · ${escHtml(title)}` : ""}
${chrome}
`; } function renderMockup(m?: string | MockupObject): string { if (!m) return ""; if (typeof m === "string") return renderMockupString(m); if (typeof m === "object" && typeof (m as MockupObject).html === "string") { return renderMockupObject(m as MockupObject); } return ""; } function renderDiagram(d?: string): string { if (!d) return ""; return `
${escHtml(d)}
`; } function renderStep(step: PlanStep, index: number): string { const num = String(index + 1).padStart(2, "0"); const bodyHtml = renderMarkdown(step.body || ""); const files = renderFiles(step.files); const callouts = (step.callouts || []).map(renderCallout).join("\n"); const code = (step.code || []).map(renderCodeBlock).join("\n"); const mockup = renderMockup(step.mockup); const diagram = renderDiagram(step.diagram); const verification = step.verification ? renderCallout({ tone: "ok", title: "Verify", body: step.verification }) : ""; return `
${num}

${escHtml(step.title)}

${files} ${bodyHtml} ${callouts} ${code} ${mockup} ${diagram} ${verification}
`; } function renderPanelItems(items: string[], keyPrefix: string, markerFn: (i: number) => string): string { return items .map((text, i) => { const key = `${keyPrefix}-${i}`; return `
  • ${markerFn(i)}
    ${renderMarkdown(text)}
  • `; }) .join("\n"); } function renderRisks(risks?: string[]): string { if (!risks || !risks.length) return ""; return ``; } function renderOpenQuestions(qs?: string[]): string { if (!qs || !qs.length) return ""; return ``; } function renderToc(plan: PlanDoc): string { return plan.steps .map((s, i) => { const n = String(i + 1).padStart(2, "0"); return `
  • ${n}. ${escHtml(s.title)}
  • `; }) .join("\n"); } // ============================================================ // Top-level rendering // ============================================================ function renderPlanHtml(plan: PlanDoc, options: { planId: string; version: number; assetBase: string; cwd: string; feedbackUrl?: string; feedbackToken?: string; previousPlan?: PlanDoc | null; previousVersion?: number; }): string { const tplPath = join(DESIGN_SYSTEM_DIR, "template.html"); if (!existsSync(tplPath)) { throw new Error( `plan-html: design system template not found at ${tplPath}. ` + "Did the design-system files get installed?", ); } const template = readFileSync(tplPath, "utf8"); const body = plan.steps.map(renderStep).join("\n\n"); const hasPrev = !!options.previousPlan; const meta = { planId: options.planId, version: options.version, title: plan.title, steps: plan.steps.map((s) => ({ key: s.key, title: s.title })), risks: (plan.risks || []).map((text, i) => ({ key: `risk-${i}`, text })), questions: (plan.open_questions || []).map((text, i) => ({ key: `question-${i}`, text })), cwd: options.cwd, feedbackUrl: options.feedbackUrl || "", feedbackToken: options.feedbackToken || "", hasPrevious: hasPrev, previousVersion: hasPrev ? (options.previousVersion ?? Math.max(1, options.version - 1)) : null, }; return template .replace(/\{\{TITLE\}\}/g, escHtml(plan.title)) .replace(/\{\{VERSION\}\}/g, String(options.version)) .replace(/\{\{GENERATED_AT\}\}/g, new Date().toLocaleString()) .replace(/\{\{CONTEXT\}\}/g, escHtml(plan.context)) .replace(/\{\{HEADER_CHIPS\}\}/g, renderHeaderChips(plan.header_chips)) .replace(/\{\{TOC\}\}/g, renderToc(plan)) .replace(/\{\{BODY\}\}/g, body) .replace(/\{\{RISKS\}\}/g, renderRisks(plan.risks)) .replace(/\{\{OPEN_QUESTIONS\}\}/g, renderOpenQuestions(plan.open_questions)) .replace(/\{\{PLAN_JSON\}\}/g, JSON.stringify(plan).replace(/ { /* ignore */ }); } catch { /* ignore */ } } // Returns the HTTP URL for a plan id when the server is up, else the file path. function planViewUrl(planBase: string, version: number, fallbackPath: string): string { if (feedbackInfo && feedbackInfo.planBase) { return `${feedbackInfo.planBase}${encodeURIComponent(`${planBase}-v${version}`)}`; } return fallbackPath; } // ============================================================ // Local feedback server // One per pi process. Accepts POSTs from rendered plan HTML pages // and writes feedback.md to /.pi/plans/feedback.md. // Bound to 127.0.0.1 on a random port; token-protected. // ============================================================ const FEEDBACK_MAX_BODY = 1_000_000; // 1MB interface FeedbackServerInfo { port: number; token: string; url: string; // POST /feedback planBase: string; // http://127.0.0.1:PORT/plan/ } let feedbackServer: Server | null = null; let feedbackInfo: FeedbackServerInfo | null = null; let feedbackStarting: Promise | null = null; const allowedCwds = new Set(); // MIME map for /asset/* responses. const ASSET_MIME: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".woff": "font/woff", ".woff2": "font/woff2", }; function mimeFor(p: string): string { const dot = p.lastIndexOf("."); if (dot < 0) return "application/octet-stream"; return ASSET_MIME[p.slice(dot).toLowerCase()] || "application/octet-stream"; } // Look up a plan HTML file across registered cwds. function findPlanHtml(planId: string): string | null { if (!/^[A-Za-z0-9._-]+$/.test(planId)) return null; // sanitize for (const cwd of allowedCwds) { const candidate = join(cwd, ".pi", "plans", `${planId}.html`); if (existsSync(candidate)) return candidate; } return null; } function registerCwd(cwd: string): string { const abs = resolvePath(cwd); allowedCwds.add(abs); return abs; } async function ensureFeedbackServer(): Promise { if (feedbackInfo) return feedbackInfo; if (feedbackStarting) return feedbackStarting; feedbackStarting = new Promise((resolveP) => { const token = randomBytes(16).toString("hex"); const srv = createServer((req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Cache-Control", "no-store"); if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } const url = req.url || ""; // --- GET /plan/ — serve a rendered plan from its disk file --- if (req.method === "GET" && url.startsWith("/plan/")) { const id = decodeURIComponent(url.slice("/plan/".length).replace(/\?.*$/, "")); const filepath = findPlanHtml(id); if (!filepath) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("plan not found"); } try { const body = readFileSync(filepath); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); return res.end(body); } catch (err) { res.writeHead(500, { "Content-Type": "text/plain" }); return res.end(String((err as Error).message || err)); } } // --- GET /asset/ --- if (req.method === "GET" && url.startsWith("/asset/")) { const rel = decodeURIComponent(url.slice("/asset/".length).replace(/\?.*$/, "")); // Strict: only allow [A-Za-z0-9._/-] components, no traversal. if (rel.split("/").some((seg) => seg === ".." || seg === "" || !/^[A-Za-z0-9._-]+$/.test(seg))) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end("bad asset path"); } const fullPath = resolvePath(join(DESIGN_SYSTEM_DIR, rel)); if (!fullPath.startsWith(resolvePath(DESIGN_SYSTEM_DIR))) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end("path escape"); } if (!existsSync(fullPath)) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("asset not found"); } try { const body = readFileSync(fullPath); res.writeHead(200, { "Content-Type": mimeFor(fullPath), "Cache-Control": "public, max-age=600" }); return res.end(body); } catch (err) { res.writeHead(500, { "Content-Type": "text/plain" }); return res.end(String((err as Error).message || err)); } } if (req.method !== "POST" || url !== "/feedback") { res.writeHead(404, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "not found" })); } const auth = req.headers["authorization"]; const provided = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : ""; if (provided !== token) { res.writeHead(401, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "unauthorized" })); } let body = ""; let aborted = false; req.on("data", (chunk) => { body += String(chunk); if (body.length > FEEDBACK_MAX_BODY) { aborted = true; res.writeHead(413, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "body too large" })); req.destroy(); } }); req.on("end", () => { if (aborted) return; let parsed: { cwd?: unknown; markdown?: unknown }; try { parsed = JSON.parse(body); } catch { res.writeHead(400, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "bad json" })); } const cwd = typeof parsed.cwd === "string" ? parsed.cwd : ""; const markdown = typeof parsed.markdown === "string" ? parsed.markdown : ""; if (!cwd || !markdown) { res.writeHead(400, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "missing cwd or markdown" })); } const absCwd = resolvePath(cwd); if (!allowedCwds.has(absCwd)) { res.writeHead(403, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "cwd not registered" })); } try { const plansDirPath = join(absCwd, ".pi", "plans"); mkdirSync(plansDirPath, { recursive: true }); const target = join(plansDirPath, FEEDBACK_FILENAME); // Final containment check: target must remain inside absCwd if (!resolvePath(target).startsWith(absCwd)) { res.writeHead(400, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "path escape" })); } writeFileSync(target, markdown); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, path: target })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: String((err as Error).message || err) })); } }); }); srv.on("error", () => { feedbackInfo = null; feedbackServer = null; resolveP(null); }); srv.listen(0, "127.0.0.1", () => { const addr = srv.address(); if (addr && typeof addr === "object" && typeof addr.port === "number") { feedbackServer = srv; feedbackInfo = { port: addr.port, token, url: `http://127.0.0.1:${addr.port}/feedback`, planBase: `http://127.0.0.1:${addr.port}/plan/`, }; resolveP(feedbackInfo); } else { try { srv.close(); } catch { /* ignore */ } resolveP(null); } }); }); const info = await feedbackStarting; feedbackStarting = null; return info; } function stopFeedbackServer(): void { if (feedbackServer) { try { feedbackServer.close(); } catch { /* ignore */ } feedbackServer = null; feedbackInfo = null; } } // ============================================================ // Planning system prompt (injected each turn while in planning phase) // ============================================================ function planningPrompt(designSystemPath: string): string { return `[PLAN MODE — HTML PLAN] You are in plan mode. Write-tools are disabled (no edit, no write). Bash is restricted to a read-only allowlist. Use read, grep, find, ls to explore. Your task: produce a plan that will be rendered into a beautiful, commentable HTML page using the plan-design-system at ${designSystemPath}. Read its README.md to understand the available components. OUTPUT REQUIREMENTS: - End your reply with a single fenced block: \`\`\`plan-json { ...strict JSON matching the schema below... } \`\`\` - Strict JSON only — no comments, no trailing commas, no unquoted keys. - Prose BEFORE the fenced block is fine and visible to the user; nothing after the closing fence. - Use components meaningfully — mockups for UI changes, diagrams for structural changes, code snippets only for illustrative micro-examples, files chips for every file you will create/edit/delete. SCHEMA: { "title": string, // short, specific "context": string, // 1–3 sentences: why, what changes, what success looks like "header_chips": [{ "label": string, "tone": "info|ok|warn|bad|accent" }], // optional, max 4 "steps": [ { "key": string, // short kebab-case slug, stable across iterations "title": string, "body": string, // markdown "files": [string], // files to create/edit/delete (not read-only) "callouts": [{ "tone": "note|warn|risk|ok", "title": string?, "body": string }], "code": [{ "lang": string, "label": string?, "source": string }], "mockup": { // HTML mockup, rendered inside a sandboxed iframe with chrome "frame": "window" | "browser" | "phone" | "none", // default: "window" "title": string?, // shown inside window/browser chrome "viewport": "desktop" | "mobile" | "auto", // default: "auto" (phone→mobile, else desktop) "html": string, // body HTML — lean on the mockup preset's defaults "css": string? // optional, scoped to this mockup's iframe }, "diagram": string, // mermaid source (flowchart, sequence, ...) "verification":string // how to validate this step works } ], "risks": [string], "open_questions": [string] } GUIDANCE: - Prefer many small steps with stable \`key\`s over one big step — comments are anchored per step, and stable keys let user comments persist across /iterate cycles. - Don't invent generic risks. Skip the field if no concrete risk applies. - Don't include files only being read. - For UI changes: include a mockup using the OBJECT form (\`{frame, title, html, ...}\`). Write real HTML and lean on the iframe's built-in preset stylesheet — plain \`
    \`, \`
    \`, \`
    \`, \`