feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+351
View File
@@ -0,0 +1,351 @@
/**
* Import a pi session shared as a gist by the issue-analysis CI workflow
* (.github/workflows/issue-analysis.yml) and switch to it.
*
* The CI job runs in a high-entropy checkout directory; this command rewrites
* the recorded cwd to the local checkout, installs the session file into the
* current session directory, and switches to it.
*
* Usage:
* /ir b4d100022aefb12f25dd2d8485e0a82a
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
* /ir https://github.com/earendil-works/pi/issues/123
*
* pi "/ir <gist-id>"
*/
import { Buffer } from "node:buffer";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, isAbsolute, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/;
const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g;
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
interface SessionHeader {
type: "session";
id: string;
cwd: string;
[key: string]: unknown;
}
interface ExportedSessionData {
header: SessionHeader | null;
entries: Array<Record<string, unknown>>;
}
interface GistFile {
filename?: string;
raw_url?: string;
content?: string;
truncated?: boolean;
}
interface GistResponse {
files?: Record<string, GistFile>;
}
interface IssueComment {
body?: string | null;
user?: { login?: string } | null;
}
function parseRef(
ref: string,
cwd: string,
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
}
const shareMatch = ref.match(SHARE_URL_RE);
if (shareMatch) return { type: "gist", id: shareMatch[1] };
const gistMatch = ref.match(GIST_URL_RE);
if (gistMatch) return { type: "gist", id: gistMatch[1] };
const issueMatch = ref.match(ISSUE_URL_RE);
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
}
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
const newlineIndex = raw.indexOf("\n");
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
let parsed: unknown;
try {
parsed = JSON.parse(firstLine);
} catch {
throw new Error("first line of session file is not valid JSON");
}
const header = parsed as Partial<SessionHeader>;
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string" || header.cwd === "") {
throw new Error("session file has no valid session header with a cwd");
}
return { header: header as SessionHeader, jsonl: raw };
}
function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: string } {
const match = html.match(SESSION_DATA_RE);
if (!match) throw new Error("HTML does not contain embedded pi session data");
let data: unknown;
try {
data = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
} catch {
throw new Error("embedded pi session data is not valid JSON");
}
const sessionData = data as Partial<ExportedSessionData>;
const header = sessionData.header;
if (!header || header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
throw new Error("embedded pi session data has no valid session header");
}
if (!Array.isArray(sessionData.entries)) {
throw new Error("embedded pi session data has no entries array");
}
const lines = [header, ...sessionData.entries].map((entry) => JSON.stringify(entry));
return { header, jsonl: `${lines.join("\n")}\n` };
}
type SessionPlatform = "windows" | "unix" | "unknown";
function escapeJsonString(value: string): string {
return JSON.stringify(value).slice(1, -1);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function trimTrailingPathSeparators(value: string): string {
return value.replace(/[\\/]+$/, "");
}
function getPathTailName(value: string): string {
const trimmed = trimTrailingPathSeparators(value);
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
}
function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined {
const trimmed = trimTrailingPathSeparators(value);
const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
if (driveMatch) {
return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") };
}
const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
if (msysMatch) {
return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") };
}
return undefined;
}
function getCwdRewriteVariants(sourceCwd: string): string[] {
const trimmed = trimTrailingPathSeparators(sourceCwd);
const variants = new Set<string>();
if (trimmed) variants.add(trimmed);
const driveParts = getWindowsDrivePathParts(trimmed);
if (driveParts) {
const rest = driveParts.rest.replace(/^\/+|\/+$/g, "");
const backslashRest = rest.replace(/\//g, "\\");
variants.add(`${driveParts.drive}:\\${backslashRest}`);
variants.add(`${driveParts.drive}:/${rest}`);
variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`);
variants.add(`/${driveParts.drive}/${rest}`);
}
return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length);
}
function getCiWorkdirName(sourceCwd: string): string | undefined {
const name = getPathTailName(sourceCwd);
return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined;
}
function detectSessionPlatform(cwd: string): SessionPlatform {
if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows";
if (cwd.startsWith("/")) return "unix";
return "unknown";
}
function getLocalPlatform(): Exclude<SessionPlatform, "unknown"> {
return process.platform === "win32" ? "windows" : "unix";
}
function getPlatformContinuationNotice(sourceCwd: string): string | undefined {
const sourcePlatform = detectSessionPlatform(sourceCwd);
const localPlatform = getLocalPlatform();
if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined;
if (localPlatform === "unix") {
return "This session was continued on a non-Windows machine; paths are now Unix style.";
}
return "This session was continued on a Windows machine; paths are now Windows style.";
}
/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
const target = escapeJsonString(targetCwd);
let rewritten = raw;
for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) {
if (sourceVariant === targetCwd) continue;
rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target);
}
const ciWorkdirName = getCiWorkdirName(sourceCwd);
if (ciWorkdirName) {
const escapedName = escapeRegExp(ciWorkdirName);
const windowsPathPatterns = [
new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"),
new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"),
];
for (const pattern of windowsPathPatterns) {
rewritten = rewritten.replace(pattern, target);
}
}
return rewritten;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
if (!response.ok) {
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
}
return await response.text();
}
async function readGistFile(file: GistFile): Promise<string> {
if (file.content && !file.truncated) return file.content;
if (!file.raw_url) throw new Error(`gist file ${file.filename ?? "<unknown>"} has no raw URL`);
return await fetchText(file.raw_url);
}
async function findIssueGistId(owner: string, repo: string, issue: string): Promise<string> {
const gistIds: string[] = [];
let page = 1;
while (true) {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue)}/comments?per_page=100&page=${page}`,
{ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } },
);
if (!response.ok) throw new Error(`failed to fetch issue comments: HTTP ${response.status}`);
const comments = (await response.json()) as IssueComment[];
for (const comment of comments) {
if (comment.user?.login !== "github-actions[bot]") continue;
for (const match of (comment.body ?? "").matchAll(GIST_URL_IN_TEXT_RE)) {
gistIds.push(match[1]);
}
}
if (comments.length < 100) break;
page++;
}
const gistId = gistIds.at(-1);
if (!gistId) throw new Error(`no github-actions gist link found in comments on ${owner}/${repo}#${issue}`);
return gistId;
}
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) throw new Error(`failed to fetch gist ${gistId}: HTTP ${response.status}`);
const gist = (await response.json()) as GistResponse;
const files = Object.values(gist.files ?? {});
const jsonlFile = files.find((file) => file.filename?.endsWith(".jsonl"));
if (jsonlFile) return parseSessionJsonl(await readGistFile(jsonlFile));
const htmlFile = files.find((file) => file.filename?.endsWith(".html"));
if (htmlFile) return decodeExportedHtml(await readGistFile(htmlFile));
throw new Error(`gist ${gistId} has no .jsonl or .html session file`);
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("ir", {
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
handler: async (args: string, ctx: ExtensionCommandContext) => {
const ref = args.trim();
if (!ref) {
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
return;
}
try {
const targetCwd = ctx.sessionManager.getCwd();
const sessionDir = ctx.sessionManager.getSessionDir();
const parsedRef = parseRef(ref, targetCwd);
ctx.ui.notify(`Importing repro session from ${ref}...`, "info");
let sourceName: string;
let decoded: { header: SessionHeader; jsonl: string };
if (parsedRef.type === "gist") {
decoded = await fetchGistSession(parsedRef.id);
sourceName = `${parsedRef.id}.jsonl`;
} else if (parsedRef.type === "issue") {
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
decoded = await fetchGistSession(gistId);
sourceName = `${gistId}.jsonl`;
} else {
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
const raw = readFileSync(parsedRef.path, "utf8");
decoded = parsedRef.path.endsWith(".html") ? decodeExportedHtml(raw) : parseSessionJsonl(raw);
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
}
const platformNotice = getPlatformContinuationNotice(decoded.header.cwd);
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
const destination = join(sessionDir, sourceName);
if (existsSync(destination)) {
const overwrite = await ctx.ui.confirm(
"Session already imported",
`Overwrite ${destination}? Local changes to that session will be lost.`,
);
if (!overwrite) {
ctx.ui.notify("Import cancelled", "warning");
return;
}
}
writeFileSync(destination, rewritten);
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
await ctx.switchSession(destination, {
withSession: async (nextCtx) => {
if (!platformNotice) return;
await nextCtx.sendMessage(
{
customType: "import-repro",
content: platformNotice,
display: true,
details: { sourceCwd: decoded.header.cwd, targetCwd },
},
{ triggerTurn: false },
);
},
});
} catch (error) {
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
}
},
});
}
+270
View File
@@ -0,0 +1,270 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Container, Text } from "@earendil-works/pi-tui";
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im;
type PromptMatch = {
kind: "pr" | "issue" | "advisory";
target: string;
};
type GhMetadata = {
title?: string;
detail?: string;
displayUrl?: string;
author?: {
login?: string;
name?: string | null;
};
};
type GitHubAdvisoryMetadata = {
ghsa_id?: string;
summary?: string;
severity?: string;
state?: string;
html_url?: string;
cve_id?: string | null;
};
type AdvisoryRef = {
owner: string;
repo: string;
ghsaId: string;
url: string;
};
function extractPromptMatch(prompt: string): PromptMatch | undefined {
const prMatch = prompt.match(PR_PROMPT_PATTERN);
if (prMatch?.[1]) {
return { kind: "pr", target: prMatch[1].trim() };
}
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
if (issueMatch?.[1]) {
return { kind: "issue", target: issueMatch[1].trim() };
}
const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN);
if (advisoryMatch?.[1]) {
return { kind: "advisory", target: advisoryMatch[1].trim() };
}
return undefined;
}
function getPromptLabel(kind: PromptMatch["kind"]): string {
if (kind === "pr") return "PR";
if (kind === "issue") return "Issue";
return "Advisory";
}
function parseAdvisoryUrl(value: string): AdvisoryRef | undefined {
const match = value.match(
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i,
);
if (!match?.[1] || !match[2] || !match[3]) return undefined;
return {
owner: match[1],
repo: match[2],
ghsaId: match[3],
url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`,
};
}
function unquoteYamlValue(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function resolveDraftPath(cwd: string, target: string): string {
if (target === "~") return homedir();
if (target.startsWith("~/")) return resolve(homedir(), target.slice(2));
return resolve(cwd, target);
}
async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise<AdvisoryRef | undefined> {
try {
const content = await readFile(resolveDraftPath(cwd, target), "utf8");
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const body = frontmatter?.[1] ?? content;
const urlMatch = body.match(/^advisory_url:\s*(.+)$/m);
if (!urlMatch?.[1]) return undefined;
return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1]));
} catch {
return undefined;
}
}
function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined {
const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state]
.map((part) => part?.trim())
.filter((part): part is string => part !== undefined && part.length > 0);
return parts.length > 0 ? parts.join(" · ") : undefined;
}
async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise<GhMetadata | undefined> {
const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target));
if (!advisoryRef) return undefined;
try {
const result = await pi.exec("gh", [
"api",
`repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`,
]);
if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url };
const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata;
return {
title: advisory.summary,
detail: formatAdvisoryDetail(advisory),
displayUrl: advisory.html_url ?? advisoryRef.url,
};
} catch {
return { displayUrl: advisoryRef.url };
}
}
async function fetchGhMetadata(
pi: ExtensionAPI,
kind: PromptMatch["kind"],
target: string,
cwd: string,
): Promise<GhMetadata | undefined> {
if (kind === "advisory") {
return fetchAdvisoryMetadata(pi, cwd, target);
}
const args =
kind === "pr"
? ["pr", "view", target, "--json", "title,author"]
: ["issue", "view", target, "--json", "title,author"];
try {
const result = await pi.exec("gh", args);
if (result.code !== 0 || !result.stdout) return undefined;
return JSON.parse(result.stdout) as GhMetadata;
} catch {
return undefined;
}
}
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) return undefined;
const name = author.name?.trim();
const login = author.login?.trim();
if (name && login) return `${name} (@${login})`;
if (login) return `@${login}`;
if (name) return name;
return undefined;
}
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
const displayTarget = metadata?.displayUrl ?? match.target;
const titleText = metadata?.title
? thm.fg("accent", metadata.title)
: thm.fg("accent", displayTarget);
const detailText = metadata?.detail ?? formatAuthor(metadata?.author);
const detailLine = detailText ? thm.fg("muted", detailText) : undefined;
const urlLine = thm.fg("dim", displayTarget);
const lines = [titleText];
if (detailLine) lines.push(detailLine);
lines.push(urlLine);
const container = new Container();
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
container.addChild(new Text(lines.join("\n"), 1, 0));
return container;
});
};
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
const label = getPromptLabel(match.kind);
const displayTarget = metadata?.displayUrl ?? match.target;
const trimmedTitle = metadata?.title?.trim();
const fallbackName = `${label}: ${match.target}`;
const desiredFallbackName = `${label}: ${displayTarget}`;
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName;
const currentName = pi.getSessionName()?.trim();
if (!currentName) {
pi.setSessionName(desiredName);
return;
}
if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) {
pi.setSessionName(desiredName);
}
};
const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => {
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => {
setWidget(ctx, match, meta);
applySessionName(ctx, match, meta);
});
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
const match = extractPromptMatch(event.prompt);
if (!match) {
return;
}
updatePromptContext(ctx, match);
});
pi.on("session_switch", async (_event, ctx) => {
rebuildFromSession(ctx);
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) return "";
if (typeof content === "string") return content;
return (
content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n") ?? ""
);
};
const rebuildFromSession = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].reverse().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return false;
const text = getUserText(entry.message.content);
return !!extractPromptMatch(text);
});
const content =
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
const text = getUserText(content);
const match = text ? extractPromptMatch(text) : undefined;
if (!match) {
ctx.ui.setWidget("prompt-url", undefined);
return;
}
updatePromptContext(ctx, match);
};
pi.on("session_start", async (_event, ctx) => {
rebuildFromSession(ctx);
});
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Redraws Extension
*
* Exposes /tui to show TUI redraw stats.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("tui", {
description: "Show TUI stats",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
let redraws = 0;
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
redraws = tui.fullRedraws;
done(undefined);
return new Text("", 0, 0);
});
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
},
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function isAssistantMessage(message: unknown): message is AssistantMessage {
if (!message || typeof message !== "object") return false;
const role = (message as { role?: unknown }).role;
return role === "assistant";
}
export default function (pi: ExtensionAPI) {
let agentStartMs: number | null = null;
pi.on("agent_start", () => {
agentStartMs = Date.now();
});
pi.on("agent_end", (event, ctx) => {
if (!ctx.hasUI) return;
if (agentStartMs === null) return;
const elapsedMs = Date.now() - agentStartMs;
agentStartMs = null;
if (elapsedMs <= 0) return;
let input = 0;
let output = 0;
let cacheRead = 0;
let cacheWrite = 0;
let totalTokens = 0;
for (const message of event.messages) {
if (!isAssistantMessage(message)) continue;
input += message.usage.input || 0;
output += message.usage.output || 0;
cacheRead += message.usage.cacheRead || 0;
cacheWrite += message.usage.cacheWrite || 0;
totalTokens += message.usage.totalTokens || 0;
}
if (output <= 0) return;
const elapsedSeconds = elapsedMs / 1000;
const tokensPerSecond = output / elapsedSeconds;
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
ctx.ui.notify(message, "info");
});
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+54
View File
@@ -0,0 +1,54 @@
---
description: Audit changelog entries before release
---
Audit changelog entries for all commits since the last release.
## Process
1. **Find the last release tag:**
```bash
git tag --sort=-version:refname | head -1
```
2. **List all commits since that tag:**
```bash
git log <tag>..HEAD --oneline
```
3. **Read each package's [Unreleased] section:**
- packages/ai/CHANGELOG.md
- packages/tui/CHANGELOG.md
- packages/coding-agent/CHANGELOG.md
4. **For each commit, check:**
- Skip: changelog updates, doc-only changes, release housekeeping
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
- Verify a changelog entry exists in the affected package(s)
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
5. **Cross-package duplication rule:**
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
6. **Add New Features section after changelog fixes:**
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
- Propose the top new features to the user for confirmation before writing them.
- Link to relevant docs and sections whenever possible.
7. **Report:**
- List commits with missing entries
- List entries that need cross-package duplication
- Add any missing entries directly
## Changelog Format Reference
Sections (in order):
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
Attribution:
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
+28
View File
@@ -0,0 +1,28 @@
---
description: Analyze GitHub issues (bugs or feature requests)
argument-hint: "<issue>"
---
Analyze GitHub issue(s): $ARGUMENTS
For each issue:
1. If running under CI (`CI=true`), do not add the `inprogress` label and do not assign the issue. Otherwise, add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example:
```sh
gh issue view <issue> --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences
```
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
4. **For bugs**:
- Ignore any root cause analysis in the issue (likely wrong)
- Read all related code files in full (no truncation)
- Trace the code path and identify the actual root cause
- Propose a fix
5. **For feature requests**:
- Do not trust implementation proposals in the issue without verification
- Read all related code files in full (no truncation)
- Propose the most concise implementation approach
- List affected files and changes needed
Do NOT implement unless explicitly asked. Analyze and propose only.
+37
View File
@@ -0,0 +1,37 @@
---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---
You are given one or more GitHub PR URLs: $@
For each PR URL, do the following in order:
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
4. Analyze the PR diff without checking out or switching to the PR branch. Use `gh pr diff`, `gh pr view`, `gh api`, and local main-branch files; if PR file contents are needed, use fetched refs with `git show <ref>:<path>` or temporary files. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
7. Provide a structured review with these sections:
- What it does: one short paragraph describing the change and its intent.
- Good: solid choices or improvements.
- Bad: concrete issues, regressions, missing tests, or risks.
- Ugly: subtle or high impact problems.
- Tests: what is covered, what is missing, and whether existing tests are adequate.
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
Output format per PR:
PR: <url>
What it does:
- ...
Good:
- ...
Bad:
- ...
Ugly:
- ...
Tests:
- ...
Open questions for you:
- ...
If no issues are found, say so under Bad and Ugly.
+163
View File
@@ -0,0 +1,163 @@
---
description: Update a GitHub security advisory for publication
argument-hint: "<advisory-url-or-draft-path>"
---
Update a GitHub security advisory for publication: $ARGUMENTS
Use `gh` for all GitHub operations. Do not publish the advisory, change its state, or request a CVE unless the user explicitly agrees or the draft markdown explicitly says `request_cve: true`.
GitHub does not expose repository security advisory comments/discussion through the documented REST OpenAPI schema or public GraphQL schema. A 404 from guessed API endpoints such as `api.github.com/repos/.../security-advisories/<GHSA>/comments`, `.../timeline`, or `.../events` is expected and is not, by itself, an auth failure. Do not use a browser session, browser cookies, or cookie extraction to fetch advisory comments. Instead, clearly tell the user that advisory comments were not included and that they can paste any relevant comments if they want them considered.
## Input handling
- If `$ARGUMENTS` is a GitHub security advisory URL, start the investigation and drafting workflow.
- If `$ARGUMENTS` is a path to an existing markdown draft, read it and apply that draft to the advisory.
- In a follow-up message after this prompt, if the user says "update", "apply", "looks good", or similar, treat it as approval to apply the previously written temp markdown draft. Re-read the file from disk before updating GitHub.
- If applying a draft and there is no known draft path, ask the user for the markdown file path.
## Initial advisory workflow
1. Parse the advisory URL into `owner`, `repo`, and `GHSA` id.
2. Fetch the advisory with:
```sh
gh api repos/<owner>/<repo>/security-advisories/<GHSA>
```
Record the advisory's original severity, CVSS vector, and CVSS score exactly as returned before proposing changes.
3. Do not fetch advisory comments/discussion unless the user pasted them into the conversation:
- Inspect the advisory JSON for references, credits, linked issues/PRs, and any discussion fields.
- Do not rely on invented API endpoints such as `/comments`, `/timeline`, or `/events`; they commonly return 404 because GitHub does not expose draft advisory comments through the public API.
- Do not use a browser session, browser cookies, or cookie extraction to fetch comments.
- Explicitly tell the user: `Advisory comments were not included because GitHub does not expose them through the public API. Paste any relevant comments if you want them considered.`
- If the user pasted comments, read and consider them.
- Never pretend comments were read.
4. Investigate independently:
- Read the advisory text, metadata, affected package(s), version ranges, CVSS, CWE, references, and linked issues/PRs/commits.
- Inspect relevant code history, releases, changelogs, package metadata, and tags.
- Determine whether the vulnerability is already fixed.
- If fixed, identify the patched version(s) and the correct affected version range.
- Do not trust the reporter's analysis without verification.
5. Discuss CVSS with the user before drafting the final update:
- Propose a CVSS vector, score, and severity.
- Explain the controversial metrics briefly.
- Ask the user to confirm or adjust it.
6. Ask whether a CVE should be requested from GitHub for this advisory.
7. Draft a publication-ready advisory markdown file under `/tmp`, for example `/tmp/sa-<GHSA>.md`. Include both the original CVSS from the advisory and the proposed/confirmed updated CVSS.
8. Tell the user:
- the path to the temp markdown file
- the original advisory URL
- that they can edit the file and then say "update" or provide the path
## Draft markdown format
The draft file must contain YAML frontmatter followed by the advisory body. Include all fields needed to update GitHub and to decide whether to request a CVE.
```markdown
---
advisory_url: https://github.com/<owner>/<repo>/security/advisories/<GHSA>
owner: <owner>
repo: <repo>
ghsa_id: <GHSA>
summary: <short advisory summary>
original_severity: <low|medium|high|critical|null>
original_cvss_vector: <original CVSS:3.1/... or null>
original_cvss_score: <original number or null>
severity: <proposed/confirmed low|medium|high|critical>
cvss_vector: <proposed/confirmed CVSS:3.1/...>
cvss_score: <proposed/confirmed number>
cwe_ids:
- CWE-...
vulnerabilities:
- package:
ecosystem: npm
name: <package-name>
vulnerable_version_range: <range>
patched_versions: <range-or-version>
request_cve: false
---
# <Advisory title>
<Concise description of the vulnerability and vulnerable behavior.>
## Info
<Technical explanation of the root cause and affected component. Focus on facts needed by defenders and maintainers. Do not include PoC steps, exploit payloads, or copy-pastable exploit strings.>
## Impact
<Who can exploit it, prerequisites, confidentiality/integrity/availability impact, and realistic deployment assumptions.>
## Affected versions
- Affected: `<range>`
- Patched: `<version or range>`
## The solution
<Describe the fix and the patched release.>
## Recommendations
<Upgrade guidance and operational mitigations.>
## Workarounds
<Workarounds if any; otherwise skip this section entirely>
## Timeline
- YYYY-MM-DD: Report received
- YYYY-MM-DD: Fix committed
- YYYY-MM-DD: Fixed version released
- YYYY-MM-DD: Advisory published
## Credits
<Reporter/researcher attribution if appropriate, otherwise skip section.>
## References
- <links to releases, commits, advisories, documentation>
```
Use the curl advisory style as inspiration: clear sections, direct language, affected/fixed version facts, recommendations, timeline, and credits. Do not include a PoC.
## Applying a draft to GitHub
When the user approves with "update"/similar or provides a markdown path:
1. Re-read the markdown file from disk. Never rely on the previously generated content in memory.
2. Parse the YAML frontmatter and body.
3. Build a JSON payload in a temporary file. Map fields as follows:
- `summary` from frontmatter
- `description` from the markdown body after frontmatter
- `severity` from frontmatter if present
- `cvss_vector_string` from `cvss_vector`
- `cwe_ids` from frontmatter
- `vulnerabilities` from frontmatter
- Do not send `original_severity`, `original_cvss_vector`, or `original_cvss_score`; those fields are retained only for audit context.
4. Update the advisory with:
```sh
gh api -X PATCH repos/<owner>/<repo>/security-advisories/<GHSA> --input /tmp/<payload>.json
```
5. If and only if the markdown frontmatter has `request_cve: true`, request a CVE with:
```sh
gh api -X POST repos/<owner>/<repo>/security-advisories/<GHSA>/cve
```
Treat "already requested" or "already assigned" as non-fatal and report it.
6. Report what was updated:
- advisory URL
- summary
- affected range
- patched versions
- original CVSS vector/score/severity
- updated CVSS vector/score/severity
- whether CVE was requested
## Safety rules
- Do not include PoC material in the final advisory body.
- Do not request a CVE unless `request_cve: true` is present in the markdown file.
- Do not publish the advisory or change its state unless the user explicitly asks.
- Do not fetch advisory comments through browser sessions or cookies. State that comments were not included and invite the user to paste relevant comments if they want them considered.
- If there is uncertainty in affected ranges, patched versions, CVSS, or CVE request status, ask the user before applying.
+40
View File
@@ -0,0 +1,40 @@
---
description: Finish the current task end-to-end with changelog, commit, and push
argument-hint: "[instructions]"
---
Wrap it.
Additional instructions: $ARGUMENTS
Determine context from the conversation history first.
Rules for context detection:
- If the conversation already mentions a GitHub issue or PR, use that existing context.
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
Unless I explicitly override something in this request, do the following in order:
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
```text
This comment is AI-generated by `/wr`
```
3. Commit only files you changed in this session.
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
6. Push the current branch.
7. If this task is tied to exactly one GitHub issue, explicitly close that issue with reason `completed` after the push so the issue-close workflows in `.github/` run. This applies to issues only, not PRs.
- Inspect `gh issue view <issue> --json state,stateReason,labels`.
- If the issue is open, run `gh issue close <issue> --reason completed`.
- If the issue is already closed with any reason other than `COMPLETED`, reopen it first, then close it with `gh issue close <issue> --reason completed` so GitHub emits a fresh close event.
- If the issue is already closed as `COMPLETED`, leave it closed unless the `inprogress` label is still present; in that case reopen it and close it again with reason `completed`.
Constraints:
- Never stage unrelated files.
- Never use `git add .` or `git add -A`.
- Run required checks before committing if code changed.
- Do not open a PR unless I explicitly ask.
- If this is not GitHub issue or PR work, do not post a GitHub comment.
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
+57
View File
@@ -0,0 +1,57 @@
---
name: add-llm-provider
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
---
# Adding a New LLM Provider (packages/ai)
A new provider touches multiple files. Work through these steps in order.
## 1. Core Types (`packages/ai/src/types.ts`)
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
- Create options interface extending `StreamOptions`.
- Add mapping to `ApiOptionsMap`.
- Add provider name to `KnownProvider` type union.
## 2. Provider Implementation (`packages/ai/src/providers/`)
Create a provider file exporting:
- `stream<Provider>()` returning `AssistantMessageEventStream`.
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
- Provider-specific options interface.
- Message/tool conversion functions.
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
## 3. Provider Exports and Lazy Registration
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
- Add logic to fetch/parse models from the provider source.
- Map to the standardized `Model` interface.
## 5. Tests (`packages/ai/test/`)
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
## 6. Coding Agent (`packages/coding-agent/`)
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
- `src/cli/args.ts`: add env var documentation.
- `README.md`: add provider setup instructions.
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
## 7. Documentation
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.