Skills › Visual Explainer › visual-explainer › extension.ts
extension.ts
503 lines · plugins/visual-explainer/extension.ts at 9d5f880
1import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2import { spawn, spawnSync } from "node:child_process";
3import { randomUUID } from "node:crypto";
4import { homedir } from "node:os";
5import { dirname, join, relative, resolve } from "node:path";
6import { createInterface } from "node:readline";
7import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
8import { prepareRenderedHtml, readSourcePath, renderPageForHost, reportLine, reportPage } from "./kit/render.mjs";
9import { lookAt, lookLines } from "./kit/look.mjs";
11type VisualExplainerParams = {
12 action: "prepare" | "render";
13 topic?: string;
14 goal?: string;
15 files?: string[];
16 audience?: string;
17 preferSubagent?: boolean;
18 filename?: string;
19 html?: string;
20 path?: string;
21 open?: boolean;
22 viewer?: Viewer;
23};
25type Viewer = "browser" | "glimpse" | "auto";
26type OpenTarget = "browser" | "glimpse";
27type OpenStatus = "disabled" | "unsupported" | "dispatched" | "failed";
29type PlanHost = { root: string; onResponse: (markdown: string, page: string) => void; closed: AbortSignal };
31type OpenResult = {
32 openAttempted: boolean;
33 openStatus: OpenStatus;
34 openTarget?: OpenTarget;
35 openError?: string;
36 fallbackFrom?: OpenTarget;
37 fallbackError?: string;
38};
40type SubagentDetection = {
41 available: boolean;
42 allToolsHasSubagent?: boolean;
43 error?: string;
44};
46type PrepareDetails = {
47 action: "prepare";
48 topic: string;
49 goal?: string;
50 audience?: string;
51 files: string[];
52 subagentAvailable: boolean;
53 subagentAllToolsAvailable?: boolean;
54 subagentDetectionError?: string;
55 outputDir: string;
56 recommendedFlow: string[];
57 subagentPrompt?: string;
58};
60type RenderDetails = OpenResult & {
61 action: "render";
62 path: string;
63 viewer: Viewer;
64 source?: string;
65 report?: PageReport;
66 problems?: string[];
67 sheets?: string[];
68};
70type PageReport = { figures: number; uncaptioned: number; dataTags: number; flows: number; proseWords: number; machines?: string[] };
72type VisualExplainerDetails = PrepareDetails | RenderDetails;
74const visualExplainerParameters = {
75 type: "object",
76 properties: {
77 action: {
78 type: "string",
79 enum: ["prepare", "render"],
80 description: "Choose prepare to plan, or render to render a page source to HTML.",
81 },
82 topic: {
83 type: "string",
84 description: "For action=prepare: what the visual explanation should cover.",
85 },
86 goal: {
87 type: "string",
88 description: "For action=prepare: what the user wants to understand, decide, or communicate.",
89 },
90 files: {
91 type: "array",
92 items: { type: "string" },
93 description: "For action=prepare: relevant files or paths the agent may inspect before generating the visual explanation.",
94 },
95 audience: {
96 type: "string",
97 description: "For action=prepare: intended audience, such as developer, PM, team, reviewer, or executive.",
98 },
99 preferSubagent: {
100 type: "boolean",
101 description: "For action=prepare: when true, recommend a scout subagent first if the subagent tool is active. Defaults to true.",
102 },
103 filename: {
104 type: "string",
105 description: "For action=render with html: basename for the output file; .html is appended if missing.",
106 },
107 html: {
108 type: "string",
109 description: "For action=render, instead of path: a page source or a complete HTML document.",
110 },
111 path: {
112 type: "string",
113 description: "For action=render, instead of html: a <name>.src.html file in the output directory.",
114 },
115 open: {
116 type: "boolean",
117 description: "For action=render: open the written file. Defaults to true.",
118 },
119 viewer: {
120 type: "string",
121 enum: ["browser", "glimpse", "auto"],
122 description: "For action=render: browser (default), glimpse, or auto (glimpseui, else the browser)."
123 },
124 },
125 required: ["action"],
126 additionalProperties: false,
127} as const;
129function detectSubagent(pi: ExtensionAPI): SubagentDetection {
130 let error: string | undefined;
132 try {
133 return { available: pi.getActiveTools().includes("subagent") };
134 } catch (caught) {
135 error = caught instanceof Error ? caught.message : String(caught);
136 }
138 try {
139 return {
140 available: false,
141 allToolsHasSubagent: pi.getAllTools().some((tool) => tool.name === "subagent"),
142 error,
143 };
144 } catch (caught) {
145 const fallbackError = caught instanceof Error ? caught.message : String(caught);
146 return { available: false, error: error ? `${error}; ${fallbackError}` : fallbackError };
147 }
148}
150type OutputDirectory = { path: string; configured: boolean };
152function resolveOutputDirectory(pi: ExtensionAPI, cwd: string): OutputDirectory {
153 const override = process.env.VISUAL_EXPLAINER_OUTPUT_DIR?.trim();
154 if (override) return { path: resolve(override), configured: true };
156 // Older Pi hosts lack this API. Keep their default/env behavior; never read project files ourselves.
157 const settings = typeof pi.getSettings === "function" ? pi.getSettings() as { visualExplainer?: { outputDir?: unknown } } : {};
158 const value = settings.visualExplainer?.outputDir;
159 if (value !== undefined && typeof value !== "string") throw new Error("visualExplainer.outputDir must be a string");
160 if (value === undefined || !value.trim()) return { path: join(homedir(), ".agent", "diagrams"), configured: false };
162 if (value === "project") {
163 // A Pi launched from a Git hook can inherit another repository's location.
164 const env = { ...process.env };
165 for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"]) delete env[key];
166 const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
167 const root = git.status === 0 ? git.stdout.replace(/\r?\n$/, "") : cwd;
168 return { path: join(root, ".agent", "diagrams"), configured: true };
169 }
170 // Nonblank settings paths are literal, including surrounding spaces and "./project".
171 const path = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : resolve(cwd, value);
172 return { path, configured: true };
173}
175// Checks the nearest existing ancestor, so a rejected directory is never created through a symlink.
176function assertResolvesToItself(path: string) {
177 let existing = path;
178 // A missing Windows drive or UNC root is its own dirname; stop there and let realpathSync throw.
179 while (!lstatSync(existing, { throwIfNoEntry: false }) && dirname(existing) !== existing) existing = dirname(existing);
180 const resolved = join(realpathSync(existing), relative(existing, path));
181 if (resolved !== path) throw new Error(`${path} must not contain symlinks and must resolve to itself (it resolves to ${resolved})`);
182}
184// Validates without creating anything.
185function assertOutputDirectory({ path, configured }: OutputDirectory) {
186 if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`${path} must not be a symlink`);
187 if (configured) assertResolvesToItself(path);
188}
190function outputFilename(input: string) {
191 const raw = input.trim().replace(/^@/, "");
193 if (!raw) throw new Error("filename is required");
194 if (raw.includes("/") || raw.includes("\\")) throw new Error("filename must be a basename, not a path");
195 if (raw.includes("..")) throw new Error("filename must not contain '..'");
196 if (/[\0-\x1f\x7f]/.test(raw)) throw new Error("filename must not contain control characters");
198 return /\.html?$/i.test(raw) ? raw : `${raw}.html`;
199}
201function assertHtmlDocument(html: string) {
202 const trimmed = html.trim();
203 if (!trimmed) throw new Error("html is required");
205 const start = trimmed.replace(/^\s*<!doctype\s+html\b[^>]*>\s*/i, "").replace(/^(?:<!--[\s\S]*?-->\s*)+/, "");
206 if (!/^<html[\s>]/i.test(start) || !/<\/html>\s*$/i.test(trimmed)) {
207 throw new Error("html must be a complete HTML document starting with <!doctype html> or <html> and ending with </html>");
208 }
209}
211type WindowListener = { onMessage: (data: unknown) => void; closed: AbortSignal };
213function runOpener(command: string, args: string[], openTarget: OpenTarget, listener?: WindowListener): Promise<OpenResult> {
214 return new Promise<OpenResult>((resolve) => {
215 const child = spawn(command, args, { detached: true, stdio: listener ? ["ignore", "pipe", "ignore"] : "ignore", windowsHide: true });
216 if (listener && child.stdout) {
217 // glimpseui prints each window.glimpse.send(...) payload as one JSON line
218 const lines = createInterface({ input: child.stdout });
219 lines.on("line", (line) => {
220 let data: unknown;
221 try { data = JSON.parse(line); } catch { return; }
222 listener.onMessage(data);
223 });
224 // Killing glimpseui ends the native window's stdin, which closes the window.
225 const closeWindow = () => { lines.close(); child.kill(); };
226 listener.closed.addEventListener("abort", closeWindow, { once: true });
227 child.once("close", () => listener.closed.removeEventListener("abort", closeWindow));
228 (child.stdout as unknown as { unref(): void }).unref();
229 }
230 let settled = false;
232 const settle = (result: OpenResult) => {
233 if (settled) return;
234 settled = true;
235 clearTimeout(timer);
236 resolve(result);
237 };
239 child.once("error", (error) => {
240 settle({
241 openAttempted: true,
242 openStatus: "failed",
243 openTarget,
244 openError: error.message,
245 });
246 });
248 child.once("exit", (code, signal) => {
249 if (code !== 0) {
250 settle({
251 openAttempted: true,
252 openStatus: "failed",
253 openTarget,
254 openError: code === null ? `opener exited with signal ${signal ?? "unknown"}` : `opener exited with code ${code}`,
255 });
256 }
257 });
259 const timer = setTimeout(() => {
260 settle({ openAttempted: true, openStatus: "dispatched", openTarget });
261 }, 250);
263 child.unref();
264 });
265}
267async function openInBrowser(path: string): Promise<OpenResult> {
268 if (process.platform === "darwin") return await runOpener("open", [path], "browser");
269 if (process.platform === "linux") return await runOpener("xdg-open", [path], "browser");
270 if (process.platform === "win32") return await runOpener("cmd", ["/c", "start", "", path], "browser");
271 return { openAttempted: false, openStatus: "unsupported", openTarget: "browser" };
272}
274async function openInGlimpse(path: string, listener?: WindowListener): Promise<OpenResult> {
275 return await runOpener("glimpseui", ["--width", "1200", "--height", "900", "--title", "Visual Explainer", "--open-links", path], "glimpse", listener);
276}
278async function openRenderedPage(path: string, viewer: Viewer, listener?: WindowListener): Promise<OpenResult> {
279 if (viewer === "browser") return await openInBrowser(path);
280 if (viewer === "glimpse") return await openInGlimpse(path, listener);
282 const glimpseResult = await openInGlimpse(path, listener);
283 if (glimpseResult.openStatus !== "failed") return glimpseResult;
285 const browserResult = await openInBrowser(path);
286 return {
287 ...browserResult,
288 fallbackFrom: "glimpse",
289 fallbackError: glimpseResult.openError ?? "glimpseui failed",
290 };
291}
293function prepareVisualExplanation(pi: ExtensionAPI, params: VisualExplainerParams, output: OutputDirectory): AgentToolResult<VisualExplainerDetails> {
294 if (typeof params.topic !== "string") throw new Error("topic must be a string for action=prepare");
295 if (params.goal !== undefined && typeof params.goal !== "string") throw new Error("goal must be a string when provided");
296 if (params.audience !== undefined && typeof params.audience !== "string") throw new Error("audience must be a string when provided");
297 if (params.files !== undefined && (!Array.isArray(params.files) || !params.files.every((file) => typeof file === "string"))) {
298 throw new Error("files must be an array of strings when provided");
299 }
300 if (params.preferSubagent !== undefined && typeof params.preferSubagent !== "boolean") {
301 throw new Error("preferSubagent must be a boolean when provided");
302 }
304 const topic = params.topic.trim();
305 if (!topic) throw new Error("topic is required");
306 assertOutputDirectory(output);
308 const goal = params.goal?.trim();
309 const audience = params.audience?.trim();
310 const files = params.files?.map((file) => file.trim()).filter(Boolean) ?? [];
311 const subagent = detectSubagent(pi);
312 const shouldUseSubagent = params.preferSubagent !== false && subagent.available;
313 const subagentPrompt = shouldUseSubagent
314 ? `Scout the codebase for a visual explanation about ${topic}.${goal ? ` The user goal is: ${goal}.` : ""}${files.length ? ` Start with these files/paths: ${files.join(", ")}.` : ""} Return concise findings, important entities/flows, and visual structure recommendations. Do not edit files.`
315 : undefined;
316 const recommendedFlow = [
317 ...(shouldUseSubagent
318 ? ["Run a scout subagent to gather repo context and identify the visual structure.", "Synthesize the findings into a concise visual outline for the target audience."]
319 : ["Gather the needed context directly in the main agent.", "Create a concise visual outline for the target audience."]),
320 "Read the relevant visual-explainer references or templates before generating the page.",
321 `Write <name>.src.html in ${output.path} using the visual-explainer skill (create the directory if needed).`,
322 "Call visual_explainer with action=render, path, optional open, and optional viewer.",
323 ];
325 const summaryLines = [
326 `Prepared visual explanation for: ${topic}`,
327 `Output directory: ${output.path}`,
328 goal ? `Goal: ${goal}` : undefined,
329 audience ? `Audience: ${audience}` : undefined,
330 shouldUseSubagent ? "Recommended start: use a scout subagent for context." : "Recommended start: gather context directly in this session.",
331 "Recommended flow:",
332 ...recommendedFlow.map((step, i) => `${i + 1}. ${step}`),
333 subagentPrompt ? `Suggested subagent task:\n${subagentPrompt}` : undefined,
334 ];
336 return {
337 content: [{ type: "text" as const, text: summaryLines.filter((line): line is string => Boolean(line)).join("\n") }],
338 details: {
339 action: "prepare",
340 topic,
341 goal,
342 audience,
343 files,
344 outputDir: output.path,
345 subagentAvailable: subagent.available,
346 subagentAllToolsAvailable: subagent.allToolsHasSubagent,
347 subagentDetectionError: subagent.error,
348 recommendedFlow,
349 subagentPrompt,
350 },
351 };
352}
354async function writeRenderedHtml(
355 filenameInput: string,
356 htmlInput: string,
357 open: boolean | undefined,
358 viewer: Viewer,
359 output: OutputDirectory,
360 signal?: AbortSignal,
361 plan?: PlanHost,
362 sourcePath?: string,
363): Promise<AgentToolResult<VisualExplainerDetails>> {
364 signal?.throwIfAborted();
366 assertHtmlDocument(htmlInput);
367 // Only a page Pi opens in Glimpse itself can send the reader's response back into this chat.
368 const sendBack = open !== false && viewer !== "browser";
369 const page = plan ? renderPageForHost(htmlInput, { root: plan.root, filename: outputFilename(filenameInput), sendBack }) : null;
370 // a plan page has its own summary in its note; every other page gets the report
371 const report: PageReport | undefined = page ? page.report : reportPage(htmlInput);
372 const filename = page?.filename ?? outputFilename(filenameInput);
373 const html = prepareRenderedHtml(page ? page.html : htmlInput);
374 const { path: outputDir, configured } = output;
375 const outputPath = join(outputDir, filename);
376 assertOutputDirectory(output);
377 mkdirSync(outputDir, { recursive: true });
378 if (configured) assertResolvesToItself(outputDir);
379 const writeAtomically = (path: string, content: string) => {
380 const existing = lstatSync(path, { throwIfNoEntry: false });
381 if (existing?.isSymbolicLink()) throw new Error(`${path} must not be a symlink`);
382 const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
383 try {
384 writeFileSync(temporaryPath, content, { encoding: "utf8", flag: "wx" });
385 if (existing) chmodSync(temporaryPath, existing.mode & 0o7777);
386 renameSync(temporaryPath, path);
387 } finally {
388 rmSync(temporaryPath, { force: true });
389 }
390 };
392 signal?.throwIfAborted();
393 // Keep the source beside the page: revisions and build receipts are small edits to it, then a new render.
394 // The source goes first, so a failed write never leaves a new page beside an old source. A source read from path stays as it is.
395 const source = sourcePath ?? (page?.source ? join(outputDir, page.source) : undefined);
396 if (source && !sourcePath) writeAtomically(source, htmlInput);
397 writeAtomically(outputPath, html);
399 signal?.throwIfAborted();
400 // a kit page is looked at in a browser, and its sheet comes back with the report, so the agent never scripts its own screenshots
401 const looked = page?.kit ? await lookAt(outputPath) : undefined;
402 signal?.throwIfAborted();
404 // a plan page or a kit page can send the reader's reply back; an ordinary page has no script to send it
405 const listener = page && plan ? {
406 onMessage: (data: unknown) => {
407 const d = data as { type?: unknown; markdown?: unknown } | null;
408 if (d?.type === "visual-explainer:response" && typeof d.markdown === "string") plan.onResponse(d.markdown, outputPath);
409 },
410 closed: plan.closed,
411 } : undefined;
412 const openResult = open === false
413 ? { openAttempted: false, openStatus: "disabled" as const }
414 : await openRenderedPage(outputPath, viewer, listener);
416 let message = `Wrote ${outputPath}.`;
417 if (openResult.openStatus === "dispatched") {
418 message += ` ${openResult.openTarget === "glimpse" ? "Glimpse" : "Browser"} open requested.`;
419 } else if (openResult.openStatus === "failed") {
420 message += ` ${openResult.openTarget === "glimpse" ? "Glimpse" : "Browser"} open failed: ${openResult.openError ?? "unknown error"}.`;
421 } else if (openResult.openStatus === "unsupported") {
422 message += " Browser opening is unsupported on this platform.";
423 }
424 if (openResult.fallbackFrom === "glimpse") {
425 message += ` Glimpse fallback reason: ${openResult.fallbackError ?? "unknown error"}.`;
426 }
427 if (page) message += page.note;
428 if (report) message += `\n${reportLine(report)}`;
429 if (looked) message += `\n${lookLines(looked).join("\n")}`;
430 if (listener && openResult.openTarget === "glimpse" && openResult.openStatus === "dispatched") {
431 message += page?.kit ? "\nThe reader may reply; it arrives here as a message." : " When the reader presses Send to agent, their response arrives here as a message; wait for it.";
432 }
434 return {
435 content: [
436 { type: "text" as const, text: message },
437 ...(looked?.sheets ?? []).map((sheet: string) => ({ type: "image" as const, data: readFileSync(sheet).toString("base64"), mimeType: "image/png" })),
438 ],
439 details: { action: "render", path: outputPath, viewer, ...openResult, ...(source ? { source } : {}), ...(report ? { report } : {}), ...(looked && !looked.skipped ? { problems: looked.problems, sheets: looked.sheets } : {}) },
440 };
441}
443function validateOpenOptions(params: VisualExplainerParams) {
444 if (params.open !== undefined && typeof params.open !== "boolean") throw new Error("open must be a boolean when provided");
445 if (params.viewer !== undefined && params.viewer !== "browser" && params.viewer !== "glimpse" && params.viewer !== "auto") {
446 throw new Error("viewer must be browser, glimpse, or auto when provided");
447 }
448}
450function validateRenderOptions(params: VisualExplainerParams): asserts params is VisualExplainerParams & { filename: string } {
451 if (typeof params.filename !== "string") throw new Error(`filename must be a string for action=${params.action}`);
452 validateOpenOptions(params);
453}
455async function renderVisualExplanation(params: VisualExplainerParams, output: OutputDirectory, plan: PlanHost, signal?: AbortSignal): Promise<AgentToolResult<VisualExplainerDetails>> {
456 if (params.path !== undefined) {
457 if (typeof params.path !== "string") throw new Error("path must be a string");
458 if (params.html !== undefined) throw new Error("pass html or path, not both");
459 if (params.filename !== undefined) throw new Error("filename is not allowed with path; the page is named after its source");
460 validateOpenOptions(params);
461 const source = readSourcePath(params.path, () => output.path);
462 return await writeRenderedHtml(source.filename, source.html, params.open, params.viewer ?? "browser", output, signal, plan, source.source);
463 }
464 validateRenderOptions(params);
465 if (typeof params.html !== "string") throw new Error("html or path is required for action=render");
466 return await writeRenderedHtml(params.filename, params.html, params.open, params.viewer ?? "browser", output, signal, plan);
467}
469export default function (pi: ExtensionAPI) {
470 // A Glimpse window can outlive this session, and pi throws on any call through a stale `pi`,
471 // so the session's end closes its windows before a late Send to agent can reach `pi`.
472 const session = new AbortController();
473 pi.on("session_shutdown", () => session.abort());
475 pi.registerTool<typeof visualExplainerParameters, VisualExplainerDetails>({
476 name: "visual_explainer",
477 label: "Visual Explainer",
478 description: "Plan visual explanations, or render a page source to HTML in the configured output directory (default ~/.agent/diagrams/).",
479 promptSnippet: "Plan or render visual-explainer pages. Use render with path for a page source.",
480 promptGuidelines: [
481 "After a plan, architecture, diff review, or substantial implementation, offer a visual explanation when it would clarify the work.",
482 "Visuals cost many tokens: ask before visual_explainer action=prepare unless the user asked for a diagram, visual review, recap, or visual plan. If prepare recommends a scout subagent and one is available, gather context first.",
483 "Use prepare to resolve the output directory before writing or locating pages. Write <name>.src.html there and pass path to action=render; revise with small edits and render again. viewer=glimpse only when the user wants a Glimpse window.",
484 "A plan source (<ve-plan> tags, references/plans.md) renders the same way, checked against the session's cwd.",
485 ],
486 parameters: visualExplainerParameters,
487 executionMode: "sequential",
488 async execute(_toolCallId: string, params: VisualExplainerParams, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: { cwd: string }) {
489 if (params.action !== "prepare" && params.action !== "render") {
490 throw new Error("action must be 'prepare' or 'render'");
491 }
493 const output = resolveOutputDirectory(pi, ctx.cwd);
494 if (params.action === "prepare") return prepareVisualExplanation(pi, params, output);
495 // a labeled message from the page rather than text the user typed; it starts a turn, or waits if the agent is busy
496 const onResponse = (markdown: string, page: string) => pi.sendMessage(
497 { customType: "visual_explainer_response", content: `The reader replied on ${page}:\n\n${markdown}`, display: true, details: { page } },
498 { triggerTurn: true, deliverAs: "followUp" },
499 );
500 return await renderVisualExplanation(params, output, { root: ctx.cwd, onResponse, closed: session.signal }, signal);
501 },
502 });
503}