repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/demo/runner.ts
Raw/**
* README demo runner: drives the real Pi TUI in a pty through a scene file,
* writes an asciicast v2 with post-production holds, and renders a GIF with
* agg. With `--assert-only` it runs the same scene without writing anything,
* so a scene doubles as a terminal E2E assertion in //:check.
*
* Scene file: extensions/<name>/demo/scene.json
* {
* "title": "footer",
* "extensions": ["footer", "cost", "context-size"], // dist packages to load; first = protagonist
* "cols": 100, "rows": 28,
* "turns": [{ "text": "…" }, { "text": "…", "tool": { "name": "read", "arguments": {…} } }],
* "steps": [
* { "expect": "regex", "hold": 1.5 },
* { "type": "/footer", "key": "enter", "expect": "regex", "hold": 2 },
* { "key": "ctrl-c" }
* ]
* }
* Time between steps in the cast is `hold` seconds (default 6 after an
* `expect`); real waiting is compressed to at most 50 ms per output chunk.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ROOT, run } from "../lib/common.ts";
export interface SceneStep {
type?: string;
/** Named key, or `alt-<char>` for an Alt chord. */
key?: string;
expect?: string;
/** Seconds held on screen after this step in the playback timeline. */
hold?: number;
/** Milliseconds to wait for `expect` before failing; default 15000. */
timeout?: number;
/** Match `expect` only against output produced after this step's input,
* for example once an interactive program has handed the terminal back. */
fresh?: boolean;
}
export interface Scene {
title: string;
extensions: string[];
cols?: number;
rows?: number;
turns?: unknown[];
steps: SceneStep[];
/** Font list for agg; default JetBrains Mono with a Nerd Font fallback. */
font?: string;
/** Files seeded into the demo project directory before Pi starts. */
files?: Record<string, string>;
/** Commands (argv arrays) run in the project directory after `files`, for
* fixtures a file list cannot express, such as a Git repository. */
setup?: string[][];
/** Regexes (visible text) that are expected to appear although they look like errors. */
allowErrors?: string[];
/** Extra user settings for the sandbox agent dir, for example `ultra.subagentExtensions`. */
settings?: Record<string, unknown>;
/** Extra `pi` CLI arguments, for example an extension flag such as `--nushell`. */
args?: string[];
}
/** Screen text that means the demo went wrong, whatever the scene expected. */
const ERROR_PATTERNS: readonly RegExp[] = [
/No more faux responses queued/u,
/\/bug sends a report to the developers/u,
/\bError: /u,
/Extension error/u,
/Failed to load extension/u,
];
const KEYS: Record<NonNullable<SceneStep["key"]>, string> = {
enter: "\r",
escape: "\u001b",
tab: "\t",
"ctrl-c": "\u0003",
"ctrl-d": "\u0004",
"ctrl-j": "\n",
"ctrl-o": "\u000f",
up: "\u001b[A",
down: "\u001b[B",
};
/** `alt-<char>` sends ESC + char, which is how terminals encode Alt chords. */
function keyBytes(key: string): string {
const alt = /^alt-(.)$/u.exec(key);
if (alt) return `\u001b${alt[1]}`;
const bytes = KEYS[key as keyof typeof KEYS];
if (!bytes) throw new Error(`unknown key "${key}"`);
return bytes;
}
const DEFAULT_HOLD = 6;
const TYPE_DELAY_S = 0.045;
/** Strip ANSI so `expect` regexes match visible text. */
function visible(text: string): string {
return text
.replaceAll(/\u001b\[[0-9;?]*[A-Za-z]/g, "")
.replaceAll(/\u001b\][^\u0007]*\u0007/g, "")
.replaceAll(/\u001b[()][A-Z0-9]/g, "")
.replaceAll(/\u001b[=>]/g, "");
}
function nuguTheme(): string {
const palette = JSON.parse(
readFileSync(join(ROOT, "assets", "nugu.json"), "utf8"),
) as {
dark: { content: Record<string, string>; ui: Record<string, string> };
};
const c = palette.dark.content;
const u = palette.dark.ui;
const hex = (v: string) => v.replace("#", "");
// agg theme: bg, fg, then 16 ANSI colours. Mirrors nugu's ghostty mapping.
return [
hex(c["backdrop"] ?? "#121212"),
hex(c["normal"] ?? "#ededed"),
hex(c["backdrop"] ?? "#121212"),
"dc5050",
hex(c["focus"] ?? "#7ba1aa"),
hex(c["important_local"] ?? "#b59ae4"),
hex(c["focus"] ?? "#7ba1aa"),
hex(c["accent"] ?? "#d151d1"),
hex(c["important_global"] ?? "#dfd3f3"),
hex(c["normal"] ?? "#ededed"),
hex(c["unfocus"] ?? "#5d5d5d"),
"dc5050",
hex(u["focus"] ?? "#9aabae"),
hex(u["important_local"] ?? "#c9b6eb"),
hex(u["focus"] ?? "#9aabae"),
hex(u["accent"] ?? "#d86dd8"),
hex(u["important_global"] ?? "#f3effb"),
hex(u["normal"] ?? "#dbdbdb"),
].join(",");
}
export interface RunResult {
castPath?: string;
gifPath?: string;
realSeconds: number;
playbackSeconds: number;
}
export async function runScene(
extensionName: string,
options: { assertOnly: boolean },
): Promise<RunResult> {
const extDir = join(ROOT, "extensions", extensionName);
const scenePath = join(extDir, "demo", "scene.json");
if (!existsSync(scenePath))
throw new Error(`${extensionName}: no demo/scene.json`);
const scene = JSON.parse(readFileSync(scenePath, "utf8")) as Scene;
const cols = scene.cols ?? 100;
const rows = scene.rows ?? 28;
// Loaded packages come from dist so the demo shows what ships.
const extensionArgs = [
...scene.extensions.flatMap((name) => {
const packageDir = join(ROOT, "dist", name);
const packagePath = join(packageDir, "package.json");
if (!existsSync(packagePath))
throw new Error(
`${extensionName}: dist/${name} missing; run mise run //:build-ext ${name}`,
);
const pkg = JSON.parse(readFileSync(packagePath, "utf8")) as {
pi?: { extensions?: string[] };
};
const entryNames = pkg.pi?.extensions;
if (!entryNames?.length)
throw new Error(
`${extensionName}: dist/${name} has no Pi extension entry`,
);
return entryNames.flatMap((entryName) => {
const entry = join(packageDir, entryName);
if (!existsSync(entry))
throw new Error(
`${extensionName}: dist/${name}/${entryName} missing`,
);
return ["-e", entry];
});
}),
];
// Isolated agent dir: no user settings, sessions, or auth leak into the demo.
// A stable path so the footer shows `…/pi-demo/<name>` instead of a pid.
const sandbox = join(tmpdir(), "pi-demo-sandbox", extensionName);
const agentDir = join(sandbox, "agent");
// The demo runs in a neutral project directory so the screen shows the
// extension, not this repository; the folder is pre-trusted to skip Pi's
// trust prompt.
const project = join(tmpdir(), "pi-demo", extensionName);
rmSync(sandbox, { recursive: true, force: true });
rmSync(project, { recursive: true, force: true });
mkdirSync(agentDir, { recursive: true });
mkdirSync(project, { recursive: true });
for (const [file, content] of Object.entries(scene.files ?? {})) {
const target = join(project, file);
mkdirSync(join(target, ".."), { recursive: true });
writeFileSync(target, content);
}
for (const [command, ...args] of scene.setup ?? []) {
if (!command) continue;
run(command, args, { cwd: project, check: true, capture: true });
}
writeFileSync(
join(agentDir, "settings.json"),
`${JSON.stringify({ theme: "dark", ...scene.settings }, null, "\t")}\n`,
);
// The demo provider lives in the sandbox agent dir so Pi discovers it the
// same way for the recorded session and for any child session an
// extension such as ultra spawns with the same PI_CODING_AGENT_DIR.
const providerDir = join(agentDir, "extensions", "demo-provider");
mkdirSync(providerDir, { recursive: true });
writeFileSync(
join(providerDir, "index.ts"),
`export { default } from ${JSON.stringify(fileURLToPath(new URL("./provider.ts", import.meta.url)))};\n`,
);
writeFileSync(
join(agentDir, "trust.json"),
`${JSON.stringify({ [project]: true }, null, "\t")}\n`,
);
const { spawn } = await import("@lydell/node-pty");
const started = Date.now();
const events: Array<[number, "o", string]> = [];
let timeline = 0;
let lastReal = started;
let screen = "";
/** Everything Pi printed, ANSI stripped, for the error scan. */
let transcript = "";
const reportPath = join(sandbox, "provider-report.json");
const allowed = (scene.allowErrors ?? []).map((p) => new RegExp(p, "u"));
// Launch the installed CLI directly; Windows cannot execute a pi.cmd shim in a PTY.
const piPackage = new URL(
"../package.json",
import.meta.resolve("@earendil-works/pi-coding-agent"),
);
const piMetadata = JSON.parse(readFileSync(piPackage, "utf8")) as {
bin: { pi: string };
};
const proc = spawn(
process.execPath,
[
fileURLToPath(new URL(piMetadata.bin.pi, piPackage)),
"--no-session",
"--no-skills",
"--no-prompt-templates",
"--no-themes",
"--provider",
"demo",
"--model",
"demo-model",
...extensionArgs,
...(scene.args ?? []),
],
{
name: "xterm-256color",
cols,
rows,
cwd: project,
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
PI_CODING_AGENT_DIR: agentDir,
PI_DEMO_SCENE: scenePath,
PI_DEMO_REPORT: reportPath,
PI_DEMO_TRACE: join(sandbox, "trace.txt"),
// No version check, package update check, or telemetry in a recording.
PI_OFFLINE: "1",
// Deterministic external editor for ctx.ui.editor and Ctrl+G.
VISUAL: "nvim",
EDITOR: "nvim",
HOME: join(sandbox, "home"),
XDG_STATE_HOME: join(sandbox, "state"),
XDG_CONFIG_HOME: join(sandbox, "config"),
XDG_CACHE_HOME: join(sandbox, "cache"),
XDG_DATA_HOME: join(sandbox, "data"),
},
},
);
proc.onData((data) => {
const now = Date.now();
timeline += Math.min((now - lastReal) / 1000, 0.05);
lastReal = now;
events.push([Number(timeline.toFixed(3)), "o", data]);
screen = (screen + data).slice(-40_000);
transcript += visible(data);
});
let exited = false;
proc.onExit(() => {
exited = true;
});
const waitFor = (pattern: string, ms: number) =>
new Promise<void>((resolve, reject) => {
const re = new RegExp(pattern, "u");
const t0 = Date.now();
const tick = () => {
if (re.test(visible(screen))) return resolve();
if (exited)
return reject(new Error(`pi exited while waiting for /${pattern}/`));
if (Date.now() - t0 > ms)
return reject(
new Error(
`timeout waiting for /${pattern}/\n--- last screen ---\n${visible(screen).slice(-1500)}`,
),
);
setTimeout(tick, 25);
};
tick();
});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
try {
// Pi renders its model before binding extension shortcuts; the loaded
// resource list appears only after binding finishes.
await waitFor("\\[Extensions\\]", 15_000);
for (const step of scene.steps) {
if (step.fresh) screen = "";
if (step.type)
for (const ch of step.type) {
proc.write(ch);
timeline += TYPE_DELAY_S;
await sleep(12);
}
if (step.key) proc.write(keyBytes(step.key));
if (step.expect) await waitFor(step.expect, step.timeout ?? 15_000);
const hold = step.hold ?? (step.expect ? DEFAULT_HOLD : 0);
timeline += hold;
// Keep only recent output for matching; the cast keeps everything.
screen = screen.slice(-12_000);
}
await sleep(200);
} catch (error) {
// Keep the whole visible transcript for diagnosis; the sandbox is temp.
writeFileSync(join(sandbox, "transcript.txt"), transcript);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n--- full transcript: ${join(sandbox, "transcript.txt")}`,
);
} finally {
if (!exited) proc.kill();
}
const realSeconds = (Date.now() - started) / 1000;
// A scene passes only when nothing on screen looked like an error and the
// model was called exactly as often as turns were scripted; otherwise an
// unplanned turn silently eats a reply and the recording lies.
for (const pattern of ERROR_PATTERNS) {
const match = pattern.exec(transcript);
if (!match) continue;
if (allowed.some((a) => a.test(match[0]))) continue;
const at = Math.max(0, match.index - 200);
throw new Error(
`screen showed an error: ${match[0]}\n--- context ---\n${transcript.slice(at, match.index + 300)}`,
);
}
if (existsSync(reportPath)) {
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
queued: number;
calls: number;
};
// Child sessions replay their own list; only the primary queue is
// accounted here.
if (report.calls !== report.queued)
throw new Error(
`scripted ${report.queued} model turn(s) but Pi made ${report.calls} model call(s); align scene.turns with the prompts the steps actually send`,
);
}
if (options.assertOnly) return { realSeconds, playbackSeconds: timeline };
const header = JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(started / 1000),
title: scene.title,
env: { TERM: "xterm-256color" },
});
const cast = `${[header, ...events.map((e) => JSON.stringify(e))].join("\n")}\n`;
const assets = join(extDir, "assets");
mkdirSync(assets, { recursive: true });
const castPath = join(assets, "demo.cast");
const gifPath = join(assets, "demo.gif");
writeFileSync(castPath, cast);
run(
"agg",
[
"--theme",
nuguTheme(),
"--font-family",
scene.font ?? "JetBrains Mono,MesloLGM Nerd Font Mono",
"--font-size",
"16",
"--idle-time-limit",
// Preserve authored reading pauses; agg otherwise caps them at 5 s.
"60",
castPath,
gifPath,
],
{ cwd: ROOT, check: true },
);
return { castPath, gifPath, realSeconds, playbackSeconds: timeline };
}
function main(argv: string[]): Promise<number> {
const assertOnly = argv.includes("--assert-only");
const names = argv.filter((a) => !a.startsWith("--"));
const withScene = readdirScenes();
// Release gates pass their whole selection; extensions without a scene are
// not a failure there, only when someone asks to record them.
const targets = names.length
? assertOnly
? names.filter((name) => withScene.includes(name))
: names
: withScene;
return (async () => {
let failed = 0;
for (const name of targets) {
try {
const result = await runScene(name, { assertOnly });
const where = result.gifPath
? ` → ${relative(ROOT, result.gifPath)} (${(statSize(result.gifPath) / 1024).toFixed(0)} KB)`
: "";
console.log(
`OK ${name}: ${result.realSeconds.toFixed(1)}s real, ${result.playbackSeconds.toFixed(1)}s playback${where}`,
);
} catch (error) {
failed++;
console.error(
`FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return failed ? 1 : 0;
})();
}
export function readdirScenes(): string[] {
const root = join(ROOT, "extensions");
return readdirSync(root)
.filter((name) => existsSync(join(root, name, "demo", "scene.json")))
.sort();
}
const statSize = (path: string): number => statSync(path).size;
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href)
main(process.argv.slice(2)).then((code) => {
process.exitCode = code;
});