import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import { openUrl } from "./src/pi-ext-browser-open.ts"; export type MockupOption = { id: string; label: string; description?: string; html: string; }; export type MockupRequest = { title: string; question: string; options: MockupOption[]; multi?: boolean; }; export type MockupResult = { action: "accept" | "iterate"; selected: string[]; notes: string; annotations: Array<{ optionId: string; x: number; y: number; note: string }>; review: { submittedAt: string; userAgent: string; viewport: { width: number; height: number; devicePixelRatio: number }; }; }; export type MockupServer = { id: string; url: string; token: string; result: Promise; present(request: MockupRequest): Promise; connected(): boolean; open(): Promise; progress(message: string): void; settled(): boolean; close(reason: string): void; }; const COOKIE = "pi_mockup_session"; const BODY_LIMIT = 1_000_000; const DEFAULT_TIMEOUT_MS = 3_600_000; const ID_RE = /^[a-zA-Z0-9._-]+$/; export function MOCKUP_FULL_DOCUMENT_ERROR(path: string): string { return `${path} must be an HTML fragment, not a full document. Remove , , , ; keep only content for the card.`; } export function validateMockupRequest(value: unknown): MockupRequest { if (!isRecord(value)) throw new Error("mockup args must be an object"); assertKnownKeys(value, new Set(["title", "question", "options", "multi"])); const title = requiredString(value.title, "title").trim(); const question = requiredString(value.question, "question").trim(); if (!title) throw new Error("title must not be empty"); if (!question) throw new Error("question must not be empty"); if (!Array.isArray(value.options)) throw new Error("options must be an array"); if (value.options.length < 1) throw new Error("options must contain at least one item"); if (value.options.length > 4) throw new Error( "options must contain at most four items; split large comparisons into multiple mockup calls.", ); const ids = new Set(); const options = value.options.map((raw, index) => { if (!isRecord(raw)) throw new Error(`options[${index}] must be an object`); const id = requiredString(raw.id, `options[${index}].id`).trim(); if (!ID_RE.test(id)) throw new Error( `options[${index}].id must contain only letters, numbers, dot, underscore, or dash`, ); if (ids.has(id)) throw new Error(`options[${index}].id duplicates a previous option id`); ids.add(id); const label = requiredString(raw.label, `options[${index}].label`).trim(); if (!label) throw new Error(`options[${index}].label must not be empty`); const html = requiredString(raw.html, `options[${index}].html`); if (isFullDocument(html)) throw new Error(MOCKUP_FULL_DOCUMENT_ERROR(`options[${index}].html`)); const description = optionalString( raw.description, `options[${index}].description`, )?.trim(); return { id, label, html, ...(description && { description }) }; }); return { title, question, options, multi: value.multi === true, }; } export async function startMockupServer( request: MockupRequest, options: { openBrowser?: boolean; browserOpener?: (url: string) => Promise; abortAgent?: () => boolean; } = {}, ): Promise { let current = validateMockupRequest(request); const browserKey = `${randomUUID()}-${randomUUID()}`; const id = randomUUID(); const clients = new Set(); let server!: Server; let closed = false; let timeout: NodeJS.Timeout | undefined; let resolveResult!: (result: MockupResult) => void; let rejectResult!: (error: Error) => void; let result = round(); let waiting = true; let stopRequested = false; function round(): Promise { return new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; }); } function clearRoundTimeout(): void { if (timeout) clearTimeout(timeout); timeout = undefined; } function armRoundTimeout(): void { clearRoundTimeout(); timeout = setTimeout( () => terminate(new Error("mockup timed out waiting for browser Submit")), DEFAULT_TIMEOUT_MS, ); timeout.unref(); } function broadcast(type: string, data: unknown): void { const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of clients) client.write(event); } function finish(value: MockupResult): void { if (closed || !waiting) return; waiting = false; clearRoundTimeout(); resolveResult(value); } function terminate(error: Error): void { if (closed) return; closed = true; clearRoundTimeout(); broadcast("progress", { message: error.message, terminal: true }); for (const client of clients) client.end(); clients.clear(); server.close(); if (waiting) rejectResult(error); } server = createServer( (req, res) => void dispatch(req, res, { get request() { return current; }, ["token"]: browserKey, clients, finish, abortAgent: () => { stopRequested = options.abortAgent?.() === true; return stopRequested; }, }), ); await listen(server); const address = server.address(); if (!address || typeof address === "string") throw new Error("Failed to bind mockup server"); const url = `http://127.0.0.1:${address.port}/?k=${browserKey}`; armRoundTimeout(); return { id, url, ["token"]: browserKey, result, present(request: MockupRequest) { if (closed) throw new Error("mockup server is closed"); if (waiting) throw new Error("mockup already awaits browser feedback"); current = validateMockupRequest(request); result = round(); waiting = true; stopRequested = false; armRoundTimeout(); broadcast("revision", {}); return result; }, connected() { return clients.size > 0; }, open() { if (options.openBrowser === false) return Promise.resolve(undefined); return (options.browserOpener ?? openBrowser)(url); }, progress(message: string) { if (!closed) broadcast("progress", { message, terminal: false }); }, settled() { if (closed || waiting) return false; terminate( new Error( stopRequested ? "Agent stopped." : "Agent finished without another mockup.", ), ); return true; }, close(reason: string) { terminate(new Error(`mockup closed: ${reason}`)); }, }; } export function renderMockupPage( request: MockupRequest, browserKey: string, ): string { const options = request.options .map((option) => renderOption(option)) .join("\n"); return ` ${esc(request.title)}
Pi mockup ${request.multi ? "· multi-select" : ""}

${esc(request.title)}

${esc(request.question)}

${options}
No option selected
`; } function renderOption(option: MockupOption): string { return `
${option.description ? `

${esc(option.description)}

` : ""}
`; } type Ctx = { readonly request: MockupRequest; token: string; clients: Set; finish(result: MockupResult): void; abortAgent(): boolean; }; async function dispatch(req: IncomingMessage, res: ServerResponse, ctx: Ctx) { try { const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (req.method === "GET" && url.pathname === "/") { if (url.searchParams.get("k") !== ctx.token) return json(res, 401, { error: "unauthorized" }); res.setHeader( "Set-Cookie", `${COOKIE}=${ctx.token}; SameSite=Strict; Path=/`, ); return html(res, 200, renderMockupPage(ctx.request, ctx.token)); } if (!authorized(req, ctx.token)) return json(res, 401, { error: "unauthorized" }); if (req.method === "GET" && url.pathname === "/api/events") { res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", }); res.write(": connected\n\n"); ctx.clients.add(res); res.once("close", () => ctx.clients.delete(res)); return; } if (req.method === "POST" && url.pathname === "/api/submit") { const result = parseResult(JSON.parse(await readBody(req)), ctx.request); json(res, 200, { ok: true }); ctx.finish(result); return; } if (req.method === "POST" && url.pathname === "/api/abort") { if (!ctx.abortAgent()) return json(res, 409, { error: "Pi has no active agent work" }); json(res, 200, { ok: true }); return; } json(res, 404, { error: "not found" }); } catch (error) { json(res, 400, { error: error instanceof Error ? error.message : String(error), }); } } function parseResult(value: unknown, request: MockupRequest): MockupResult { if (!isRecord(value)) throw new Error("submit body must be an object"); const action = enumString( value.action, new Set(["accept", "iterate"]), "action", ); if (!Array.isArray(value.selected)) throw new Error("selected must be an array"); const optionIds = new Set(request.options.map((o) => o.id)); const selected = value.selected.map((v, i) => { const id = requiredString(v, `selected[${i}]`); if (!optionIds.has(id)) throw new Error(`selected[${i}] must match an option id`); return id; }); if (!request.multi && selected.length > 1) throw new Error( "selected must contain at most one item unless multi is true", ); const notes = optionalString(value.notes, "notes")?.trim() ?? ""; if (!Array.isArray(value.annotations)) throw new Error("annotations must be an array"); const annotations = value.annotations.map((raw, index) => parseAnnotation(raw, index, optionIds), ); const client = isRecord(value.client) ? value.client : {}; const viewport = isRecord(client.viewport) ? client.viewport : {}; const review = { submittedAt: new Date().toISOString(), userAgent: optionalString(client.userAgent, "client.userAgent") ?? "", viewport: { width: nonNegativeNumber(viewport.width, "client.viewport.width"), height: nonNegativeNumber(viewport.height, "client.viewport.height"), devicePixelRatio: nonNegativeNumber( viewport.devicePixelRatio, "client.viewport.devicePixelRatio", ), }, }; return { action, selected, notes, annotations, review }; } function parseAnnotation( value: unknown, index: number, optionIds: Set, ): MockupResult["annotations"][number] { if (!isRecord(value)) throw new Error(`annotations[${index}] must be an object`); const optionId = requiredString( value.optionId, `annotations[${index}].optionId`, ); if (!optionIds.has(optionId)) throw new Error(`annotations[${index}].optionId must match an option id`); const x = unitNumber(value.x, `annotations[${index}].x`); const y = unitNumber(value.y, `annotations[${index}].y`); const note = requiredString(value.note, `annotations[${index}].note`).trim(); if (!note) throw new Error(`annotations[${index}].note must not be empty`); return { optionId, x, y, note }; } function isFullDocument(html: string): boolean { const stripped = html.replaceAll( /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "", ); return / cookie === `${COOKIE}=${browserKey}`) ); } async function readBody(req: IncomingMessage): Promise { req.setEncoding("utf8"); let body = ""; for await (const chunk of req) { body += String(chunk); if (Buffer.byteLength(body, "utf8") > BODY_LIMIT) throw new Error("request body too large"); } return body; } function html(res: ServerResponse, status: number, body: string): void { res.statusCode = status; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); res.end(body); } function json(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(body)); } function listen(server: Server): Promise { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); } async function openBrowser(url: string): Promise { if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; return (await openUrl(url, { timeoutMs: 1500 })) !== undefined; } function requiredString(value: unknown, name: string): string { if (typeof value !== "string") throw new Error(`${name} must be a string`); return value; } function optionalString(value: unknown, name: string): string | undefined { if (value === undefined) return undefined; return requiredString(value, name); } function unitNumber(value: unknown, name: string): number { const number = nonNegativeNumber(value, name); if (number > 1) throw new Error(`${name} must be a number between 0 and 1`); return number; } function nonNegativeNumber(value: unknown, name: string): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`); return value; } function enumString( value: unknown, choices: ReadonlySet, name: string, ): T { if (typeof value === "string" && choices.has(value as T)) return value as T; throw new Error(`invalid ${name}`); } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } function assertKnownKeys( value: Record, allowed: ReadonlySet, ): void { for (const key of Object.keys(value)) { if (!allowed.has(key)) throw new Error(`${key} is not a supported mockup parameter`); } } function esc(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function jsonScript(value: string): string { return JSON.stringify(value).replaceAll("<", "\\u003c"); } const FRAME_CSS = String.raw` :root { color-scheme: dark light; --bg:#101010; --panel:#181818; --text:#ededed; --muted:#9a9a9a; --line:#ededed; --soft:#2a2a2a; --accent:#d86dd8; --bad:#ff6b6b; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } @media (prefers-color-scheme: light) { :root { --bg:#f4f0f4; --panel:#fff; --text:#151515; --muted:#666; --line:#151515; --soft:#e4dfe4; --accent:#a900a9; } } * { box-sizing:border-box; } body { margin:0; min-height:100vh; display:grid; grid-template-rows:auto minmax(0, 1fr) auto; background:var(--bg); color:var(--text); } button,input,textarea { font:inherit; } button { border:2px solid var(--line); background:var(--text); color:var(--bg); padding:.55rem .8rem; font-weight:900; cursor:pointer; } button.secondary { background:var(--panel); color:var(--text); } button:focus-visible,input:focus-visible,textarea:focus-visible { outline:3px solid var(--accent); outline-offset:2px; } .topbar { border-bottom:2px solid var(--line); background:var(--panel); padding:1rem; } h1 { margin:0; font-size:1.15rem; text-transform:uppercase; letter-spacing:.04em; } p { margin:.25rem 0 0; color:var(--muted); } .eyebrow { color:var(--accent); font-weight:900; text-transform:uppercase; font-size:.72rem; } .drawer-actions { display:flex; flex-wrap:wrap; gap:.6rem; justify-content:flex-end; } .stage { padding:1rem; display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr)); gap:1rem; overflow:auto; } .option-card { border:2px solid var(--line); background:var(--panel); display:grid; grid-template-rows:auto auto 1fr; min-height:280px; position:relative; } .option-card.selected { border-color:var(--accent); box-shadow:6px 6px 0 color-mix(in srgb, var(--accent), transparent 55%); } .option-head { border-bottom:2px solid var(--line); padding:.7rem; display:flex; justify-content:space-between; gap:.7rem; align-items:center; } .option-head label { display:flex; align-items:center; gap:.55rem; cursor:pointer; } .option-head input { width:1.1rem; height:1.1rem; accent-color:var(--accent); } .pin-toggle[aria-pressed="true"] { background:var(--accent); color:white; border-color:var(--accent); } .option-card.pin-armed .fragment { cursor:crosshair; } .option-description { padding:0 .7rem .7rem; margin:.55rem 0 0; } .fragment { min-height:180px; padding:1rem; position:relative; overflow:auto; } .fragment-content { display:block; min-height:inherit; } .pin { position:absolute; width:24px; height:24px; border:2px solid var(--accent); background:var(--bg); color:var(--accent); border-radius:50%; display:grid; place-items:center; font-weight:900; transform:translate(-50%,-50%); z-index:5; } .pin-bubble { position:absolute; z-index:8; width:min(320px, calc(100% - 1rem)); border:2px solid var(--accent); background:var(--panel); padding:.6rem; display:grid; gap:.45rem; box-shadow:5px 5px 0 color-mix(in srgb, var(--accent), transparent 45%); } .pin-bubble textarea { width:100%; min-height:4rem; border:1px solid var(--line); background:var(--bg); color:var(--text); padding:.4rem; } .pin-bubble .bubble-actions { display:flex; gap:.45rem; justify-content:flex-end; } .drawer { border-top:2px solid var(--line); background:var(--panel); padding:.75rem; display:grid; grid-template-rows:auto minmax(0, 1fr); gap:.6rem; min-height:190px; max-height:42vh; } .drawer-head { grid-row:1; display:flex; align-items:center; justify-content:space-between; gap:1rem; position:relative; z-index:2; } .selection-summary { color:var(--muted); } .feedback-grid { grid-row:2; display:grid; grid-template-columns:minmax(220px,1fr) minmax(220px,1fr); gap:.75rem; min-height:0; } .feedback-pane { border:1px solid var(--soft); background:var(--bg); padding:.6rem; display:grid; grid-template-rows:auto minmax(0,1fr); gap:.45rem; min-width:0; min-height:0; overflow:hidden; } .feedback-pane h2 { margin:0; color:var(--accent); font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; } #annotations-panel { min-height:0; overflow:auto; } #global-notes { width:100%; height:100%; min-height:5rem; resize:none; border:2px solid var(--line); background:var(--bg); color:var(--text); padding:.7rem; } .annotation-row { display:grid; grid-template-columns:max-content 1fr max-content; gap:.6rem; align-items:center; border:1px solid var(--soft); padding:.45rem; margin-bottom:.45rem; } .annotation-row textarea { width:100%; min-height:2.5rem; border:1px solid var(--line); background:var(--bg); color:var(--text); padding:.35rem; } .empty { color:var(--muted); padding:.7rem; border:1px dashed var(--soft); } .progress { position:fixed; inset:0; z-index:20; background:color-mix(in srgb, var(--bg), transparent 12%); display:grid; place-items:center; padding:1rem; } .progress[hidden] { display:none; } .progress-card { width:min(560px,100%); border:2px solid var(--line); background:var(--panel); padding:1rem; display:grid; grid-template-columns:auto 1fr auto; gap:1rem; align-items:center; box-shadow:8px 8px 0 color-mix(in srgb, var(--accent), transparent 45%); } .progress-card h2 { margin:0; font-size:1rem; } .spinner { width:1.4rem; height:1.4rem; border:3px solid var(--soft); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; } .progress.terminal .spinner { animation:none; border-color:var(--accent); } @keyframes spin { to { transform:rotate(360deg); } } @media (max-width:720px) { body { grid-template-rows:auto minmax(260px,1fr) auto; } .topbar { padding:.75rem; } .stage { padding:.75rem; } .drawer { max-height:none; } .drawer-head { display:grid; align-items:start; } .drawer-actions { justify-content:start; } .feedback-grid { grid-template-columns:1fr; } .global-pane { order:1; } .annotations-pane { order:2; } } `; const CLIENT_JS = String.raw` const multi = document.body.dataset.multi === "true"; const selected = new Set(); const annotations = []; let armed = null; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; function reviveFragments() { $$('[data-fragment]').forEach(frag => { const content = $('[data-fragment-content]', frag); const data = $('[data-fragment-html]', frag); if (!content || !data) return; const root = content.attachShadow({ mode:'open' }); root.innerHTML = JSON.parse(data.textContent || '""'); root.querySelectorAll('script').forEach(old => { const script = document.createElement('script'); [...old.attributes].forEach(attr => script.setAttribute(attr.name, attr.value)); script.textContent = old.textContent; // Shadow-tree scripts have no currentScript; expose their root on the executing script. Object.defineProperty(script, 'mockupRoot', { value:root }); const pending = Boolean(script.src) || script.type === 'module'; if (pending) { script.addEventListener('load', () => script.remove(), { once:true }); script.addEventListener('error', () => script.remove(), { once:true }); } // Retain inert originals so fragment-local data scripts remain readable. document.head.append(script); if (!pending) script.remove(); }); data.remove(); }); } function sync() { closeBubble(); $$(".option-card").forEach(card => { const id = card.dataset.optionId; const is = selected.has(id); card.classList.toggle("selected", is); card.classList.toggle("pin-armed", armed === id); $("input", card).checked = is; const pin = $(".pin-toggle", card); pin.hidden = !is; pin.setAttribute("aria-pressed", String(armed === id)); pin.textContent = armed === id ? "Pinning…" : "Pin note"; }); $("#selection-summary").textContent = selected.size ? "Selected: " + [...selected].join(", ") : "No option selected"; $("#annotation-count").textContent = String(annotations.length); renderAnnotations(); } function select(id) { if (!multi) selected.clear(); if (multi && selected.has(id)) selected.delete(id); else selected.add(id); if (!selected.has(armed)) armed = null; sync(); } function addPin(id, event) { const frag = event.currentTarget; const rect = frag.getBoundingClientRect(); const pin = { optionId:id, x:(event.clientX - rect.left + frag.scrollLeft) / frag.scrollWidth, y:(event.clientY - rect.top + frag.scrollTop) / frag.scrollHeight, note:"" }; annotations.push(pin); armed = null; sync(); showBubble(annotations.indexOf(pin)); } function closeBubble() { const bubble = $(".pin-bubble"); if (bubble) bubble.remove(); } function showBubble(index) { closeBubble(); const a = annotations[index]; if (!a) return; const frag = $("[data-fragment=\"" + CSS.escape(a.optionId) + "\"]"); if (!frag) return; const bubble = document.createElement("div"); bubble.className = "pin-bubble"; if (a.x > 0.5) bubble.style.right = Math.min(82, Math.max(2, (1 - a.x) * 100)) + "%"; else bubble.style.left = Math.min(82, Math.max(2, a.x * 100)) + "%"; bubble.style.top = Math.min(82, Math.max(2, a.y * 100)) + "%"; bubble.innerHTML = '
'; const text = $("textarea", bubble); text.value = a.note; text.addEventListener("input", () => { a.note = text.value; renderAnnotations(false); }); $("[data-close]", bubble).addEventListener("click", closeBubble); $("[data-delete]", bubble).addEventListener("click", () => { annotations.splice(index, 1); sync(); }); frag.append(bubble); text.focus(); } function renderPins() { $$(".pin").forEach(p => p.remove()); annotations.forEach((a, i) => { const frag = $("[data-fragment=\"" + CSS.escape(a.optionId) + "\"]"); if (!frag) return; const button = document.createElement("button"); button.type = "button"; button.className = "pin"; button.textContent = String(i + 1); button.style.left = "clamp(14px, " + (a.x * 100) + "%, calc(100% - 14px))"; button.style.top = "clamp(14px, " + (a.y * 100) + "%, calc(100% - 14px))"; button.addEventListener("click", e => { e.stopPropagation(); showBubble(i); }); frag.append(button); }); } function renderAnnotations(redrawPins = true) { if (redrawPins) renderPins(); const panel = $("#annotations-panel"); panel.replaceChildren(); if (!annotations.length) { const empty = document.createElement("div"); empty.className = "empty"; empty.textContent = "No annotations. Select an option, press Pin note, then click inside its mockup."; panel.append(empty); return; } annotations.forEach((a, i) => { const row = document.createElement("div"); row.className = "annotation-row"; row.innerHTML = "" + (i + 1) + " · " + a.optionId + ""; const text = $("textarea", row); text.value = a.note; text.addEventListener("input", () => { a.note = text.value; }); $("button", row).addEventListener("click", () => { annotations.splice(i, 1); sync(); }); panel.append(row); }); } $$(".option-card").forEach(card => { const id = card.dataset.optionId; $("input", card).addEventListener("change", () => select(id)); $(".option-head", card).addEventListener("click", e => { if (!e.target.closest("label,button")) select(id); }); $(".pin-toggle", card).addEventListener("click", e => { e.stopPropagation(); armed = armed === id ? null : id; sync(); }); $(".fragment", card).addEventListener("click", e => { if (armed === id) addPin(id, e); else select(id); }); }); reviveFragments(); let progressStarted = 0; let progressTimer = null; function showProgress(message, terminal = false) { const progress = $("#progress"); progress.hidden = false; progress.classList.toggle("terminal", terminal); $("#progress-title").textContent = message; $("#stop-agent").hidden = terminal; if (!progressStarted) progressStarted = Date.now(); const updateTime = () => { $("#progress-time").textContent = Math.floor((Date.now() - progressStarted) / 1000) + "s elapsed"; }; updateTime(); if (progressTimer) clearInterval(progressTimer); if (!terminal) progressTimer = setInterval(updateTime, 1000); } const events = new EventSource("/api/events"); events.addEventListener("revision", () => location.reload()); events.addEventListener("progress", event => { const state = JSON.parse(event.data); showProgress(state.message, state.terminal === true); }); async function submit(action) { if (action === "iterate") showProgress("Feedback received. Pi is working."); const body = { action, selected:[...selected], notes:$("#global-notes").value.trim(), annotations:annotations.filter(a => a.note.trim()), client:{ userAgent:navigator.userAgent, viewport:{ width:window.innerWidth, height:window.innerHeight, devicePixelRatio:window.devicePixelRatio }, }, }; const res = await fetch("/api/submit", { method:"POST", headers:{ Authorization:"Bearer " + window.__MOCKUP_TOKEN__, "Content-Type":"application/json" }, body:JSON.stringify(body) }); if (!res.ok) { $("#progress").hidden = true; alert(await res.text()); return; } if (action === "accept") { showProgress("Accepted. You can close this tab.", true); setTimeout(() => window.close(), 100); } } $("#stop-agent").addEventListener("click", async () => { showProgress("Stopping agent…"); const res = await fetch("/api/abort", { method:"POST", headers:{ Authorization:"Bearer " + window.__MOCKUP_TOKEN__ } }); if (!res.ok) showProgress("Pi has no active agent work.", true); }); $("#accept").addEventListener("click", () => submit("accept")); $("#iterate").addEventListener("click", () => submit("iterate")); sync(); `;