diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs
index 59723d1b..b5d9b9b2 100755
--- a/eng/generate-website-data.mjs
+++ b/eng/generate-website-data.mjs
@@ -97,6 +97,10 @@ function formatDisplayName(value) {
.join(" ");
}
+function normalizeText(value, fallback = "") {
+ return typeof value === "string" ? value.trim() : fallback;
+}
+
/**
* Find the latest git-modified date for any file under a directory.
*/
@@ -670,6 +674,76 @@ function generatePluginsData(gitDates) {
/**
* Generate canvas extensions metadata
*/
+function getExtensionAssetInfo(extensionDir, relPath, ref) {
+ const assetDir = path.join(extensionDir, "assets");
+
+ if (!fs.existsSync(assetDir)) {
+ return null;
+ }
+
+ const imageExtensions = new Set([
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".gif",
+ ]);
+
+ const preferredNames = [
+ "preview.png",
+ "preview.jpg",
+ "preview.jpeg",
+ "preview.webp",
+ "preview.gif",
+ "screenshot.png",
+ "screenshot.jpg",
+ "screenshot.jpeg",
+ "screenshot.webp",
+ "screenshot.gif",
+ "image.png",
+ "image.jpg",
+ "image.jpeg",
+ "image.webp",
+ "image.gif",
+ ];
+
+ for (const candidate of preferredNames) {
+ const candidatePath = path.join(assetDir, candidate);
+ if (fs.existsSync(candidatePath)) {
+ const assetPath = `${relPath}/assets/${candidate}`;
+ return {
+ assetPath,
+ imageUrl: buildRepoImageUrl(assetPath, ref),
+ };
+ }
+ }
+
+ const files = fs
+ .readdirSync(assetDir)
+ .filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))
+ .sort((a, b) => a.localeCompare(b));
+
+ if (files.length === 0) {
+ return null;
+ }
+
+ const assetFile = files[0];
+ const assetPath = `${relPath}/assets/${assetFile}`;
+
+ return {
+ assetPath,
+ imageUrl: buildRepoImageUrl(assetPath, ref),
+ };
+}
+
+function buildRepoImageUrl(assetPath, ref) {
+ const encodedAssetPath = assetPath
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+ return `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`;
+}
+
function generateExtensionsData(gitDates, commitSha) {
const extensions = [];
@@ -679,19 +753,87 @@ function generateExtensionsData(gitDates, commitSha) {
const extensionDirs = fs
.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
- .filter((entry) => entry.isDirectory());
+ .filter((entry) => {
+ if (!entry.isDirectory()) return false;
+ const extensionEntryPoint = path.join(
+ EXTENSIONS_DIR,
+ entry.name,
+ "extension.mjs"
+ );
+ return fs.existsSync(extensionEntryPoint);
+ });
for (const dir of extensionDirs) {
const relPath = `extensions/${dir.name}`;
+ const assetInfo = getExtensionAssetInfo(
+ path.join(EXTENSIONS_DIR, dir.name),
+ relPath,
+ commitSha
+ );
+
extensions.push({
id: dir.name,
name: formatDisplayName(dir.name),
+ description: "Canvas extension",
path: relPath,
ref: commitSha,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
+ imageUrl: assetInfo?.imageUrl || null,
+ assetPath: assetInfo?.assetPath || null,
+ installUrl: `https://github.com/github/awesome-copilot/tree/${commitSha}/${relPath.replace(
+ /\\/g,
+ "/"
+ )}`,
+ sourceUrl: null,
+ external: false,
});
}
+ const externalJsonPath = path.join(EXTENSIONS_DIR, "external.json");
+ if (fs.existsSync(externalJsonPath)) {
+ try {
+ const externalExtensions = JSON.parse(
+ fs.readFileSync(externalJsonPath, "utf-8")
+ );
+ if (Array.isArray(externalExtensions)) {
+ for (const ext of externalExtensions) {
+ const name = normalizeText(ext?.name);
+ const installUrl = normalizeText(ext?.installUrl);
+ const sourceUrl = normalizeText(ext?.sourceUrl || installUrl);
+ if (!name || !installUrl) {
+ continue;
+ }
+
+ const id = normalizeText(ext?.id || name.toLowerCase().replace(/\s+/g, "-"));
+ let imageUrl = normalizeText(ext?.imageUrl);
+ let assetPath = null;
+ const imagePath = normalizeText(ext?.imagePath);
+ if (!imageUrl && imagePath) {
+ const repoAssetPath = imagePath.replace(/\\/g, "/");
+ imageUrl = buildRepoImageUrl(repoAssetPath, commitSha);
+ assetPath = repoAssetPath;
+ }
+
+ extensions.push({
+ id,
+ name,
+ description: normalizeText(ext?.description, "External canvas extension"),
+ path: null,
+ ref: null,
+ lastUpdated: null,
+ imageUrl: imageUrl || null,
+ assetPath,
+ installUrl,
+ sourceUrl: sourceUrl || null,
+ external: true,
+ });
+ }
+ }
+ } catch (e) {
+ console.warn(`Failed to parse external extensions: ${e.message}`);
+ }
+ }
+
const sortedExtensions = extensions.sort((a, b) =>
a.name.localeCompare(b.name)
);
diff --git a/extensions/accessibility-kanban/assets/preview.png b/extensions/accessibility-kanban/assets/preview.png
new file mode 100644
index 00000000..3a2e8ae3
Binary files /dev/null and b/extensions/accessibility-kanban/assets/preview.png differ
diff --git a/extensions/backlog-swipe-triage/README.md b/extensions/backlog-swipe-triage/README.md
new file mode 100644
index 00000000..291e6ea9
--- /dev/null
+++ b/extensions/backlog-swipe-triage/README.md
@@ -0,0 +1,8 @@
+# Backlog Swipe Triage
+
+Swipe-driven backlog triage canvas for reviewing open issues, applying quick decisions, and starting implementation sessions.
+
+## Assets
+
+- `assets/preview.png` — preferred screenshot path for the triage experience.
+- `assets/swipe-canvas-triage.png` — existing reference screenshot kept for compatibility.
diff --git a/extensions/backlog-swipe-triage/assets/preview.png b/extensions/backlog-swipe-triage/assets/preview.png
new file mode 100644
index 00000000..ee411be0
Binary files /dev/null and b/extensions/backlog-swipe-triage/assets/preview.png differ
diff --git a/extensions/backlog-swipe-triage/extension.mjs b/extensions/backlog-swipe-triage/extension.mjs
new file mode 100644
index 00000000..f60fb07f
--- /dev/null
+++ b/extensions/backlog-swipe-triage/extension.mjs
@@ -0,0 +1,2169 @@
+import { createServer } from "node:http";
+import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const servers = new Map();
+const extensionDir = fileURLToPath(new URL(".", import.meta.url));
+const artifactsDir = path.join(extensionDir, "artifacts");
+const stateFile = path.join(artifactsDir, "backlog-triage-state.json");
+const decisions = ["assign_agent", "needs_info", "not_now", "close", "ignore"];
+const execFileAsync = promisify(execFile);
+const MAX_SYNC_ISSUES = 200;
+const defaultFilters = {
+ timeWindow: "any",
+ labels: [],
+ assignees: [],
+ query: "",
+ sortBy: "updated-desc",
+};
+const filterSchema = {
+ type: "object",
+ properties: {
+ timeWindow: { type: "string", enum: ["any", "1d", "3d", "7d", "14d", "30d", "90d"] },
+ labels: { type: "array", items: { type: "string" } },
+ assignees: { type: "array", items: { type: "string" } },
+ query: { type: "string" },
+ sortBy: { type: "string", enum: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"] },
+ },
+ additionalProperties: false,
+};
+let activeSession = null;
+const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
+
+let storage = { boards: {} };
+let storageLoaded = false;
+let persistStorageQueue = Promise.resolve();
+
+async function ensureStorageLoaded() {
+ if (storageLoaded) {
+ return;
+ }
+ await fs.mkdir(artifactsDir, { recursive: true });
+ try {
+ const raw = await fs.readFile(stateFile, "utf8");
+ storage = JSON.parse(raw);
+ } catch (error) {
+ if (error && error.code !== "ENOENT") {
+ throw error;
+ }
+ storage = { boards: {} };
+ }
+ storageLoaded = true;
+}
+
+async function persistStorage() {
+ await fs.mkdir(artifactsDir, { recursive: true });
+ const snapshot = JSON.stringify(storage, null, 2);
+ persistStorageQueue = persistStorageQueue
+ .catch(() => undefined)
+ .then(async () => {
+ const tempStateFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
+ await fs.writeFile(tempStateFile, snapshot, "utf8");
+ await fs.rename(tempStateFile, stateFile);
+ });
+ await persistStorageQueue;
+}
+
+function normalizeText(value, fallback = "") {
+ return typeof value === "string" ? value.trim() : fallback;
+}
+
+function escapeHtml(value) {
+ return normalizeText(value).replace(/[&<>"']/g, (char) => {
+ if (char === "&") return "&";
+ if (char === "<") return "<";
+ if (char === ">") return ">";
+ if (char === '"') return """;
+ return "'";
+ });
+}
+
+function normalizeStringArray(values) {
+ if (!Array.isArray(values)) {
+ return [];
+ }
+ return values.map((value) => normalizeText(value)).filter(Boolean);
+}
+
+function normalizeFilters(raw, fallback = defaultFilters) {
+ const merged = raw && typeof raw === "object" ? { ...fallback, ...raw } : { ...fallback };
+ const legacyAssignee = normalizeText(merged.assignee);
+ return {
+ timeWindow: ["any", "1d", "3d", "7d", "14d", "30d", "90d"].includes(merged.timeWindow) ? merged.timeWindow : "any",
+ labels: normalizeStringArray(merged.labels),
+ assignees: legacyAssignee ? [legacyAssignee] : normalizeStringArray(merged.assignees),
+ query: normalizeText(merged.query).toLowerCase(),
+ sortBy: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"].includes(merged.sortBy)
+ ? merged.sortBy
+ : "updated-desc",
+ };
+}
+
+function parseDateToMs(value) {
+ const timestamp = Date.parse(value || "");
+ return Number.isFinite(timestamp) ? timestamp : 0;
+}
+
+function getTimeWindowMs(timeWindow) {
+ if (timeWindow === "1d") return 1 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "3d") return 3 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "7d") return 7 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "14d") return 14 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "30d") return 30 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "90d") return 90 * 24 * 60 * 60 * 1000;
+ return 0;
+}
+
+function getIssueLabels(issue) {
+ return Array.isArray(issue?.labels) ? issue.labels.map((label) => normalizeText(label?.name).toLowerCase()).filter(Boolean) : [];
+}
+
+function getIssueAssignees(issue) {
+ return Array.isArray(issue?.assignees)
+ ? issue.assignees.map((assignee) => normalizeText(assignee?.login).toLowerCase()).filter(Boolean)
+ : [];
+}
+
+function issueMatchesFilters(issue, filters) {
+ const now = Date.now();
+ const cutoffWindow = getTimeWindowMs(filters.timeWindow);
+ if (cutoffWindow > 0) {
+ const updatedAtMs = parseDateToMs(issue.updatedAt);
+ if (!updatedAtMs || now - updatedAtMs > cutoffWindow) {
+ return false;
+ }
+ }
+
+ const issueLabels = getIssueLabels(issue);
+ const requiredLabels = filters.labels.map((label) => label.toLowerCase());
+ if (requiredLabels.length > 0) {
+ if (!requiredLabels.some((label) => issueLabels.includes(label))) {
+ return false;
+ }
+ }
+
+ const assigneeFilters = normalizeStringArray(filters.assignees).map((assignee) => assignee.toLowerCase());
+ if (assigneeFilters.length > 0) {
+ const assignees = getIssueAssignees(issue);
+ const isUnassignedMatch = assigneeFilters.includes("unassigned") && assignees.length === 0;
+ const hasNamedMatch = assigneeFilters.some((wanted) => wanted !== "unassigned" && assignees.includes(wanted));
+ if (!isUnassignedMatch && !hasNamedMatch) {
+ return false;
+ }
+ }
+
+ if (filters.query) {
+ const haystack = `${normalizeText(issue.title)} ${normalizeText(issue.body || "")}`.toLowerCase();
+ if (!haystack.includes(filters.query)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function sortIssues(issues, sortBy) {
+ const sorted = [...issues];
+ if (sortBy === "random") {
+ for (let i = sorted.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [sorted[i], sorted[j]] = [sorted[j], sorted[i]];
+ }
+ return sorted;
+ }
+ sorted.sort((left, right) => {
+ if (sortBy === "created-asc") {
+ return parseDateToMs(left.createdAt) - parseDateToMs(right.createdAt);
+ }
+ if (sortBy === "created-desc") {
+ return parseDateToMs(right.createdAt) - parseDateToMs(left.createdAt);
+ }
+ if (sortBy === "updated-asc") {
+ return parseDateToMs(left.updatedAt) - parseDateToMs(right.updatedAt);
+ }
+ if (sortBy === "title-asc") {
+ return normalizeText(left.title).localeCompare(normalizeText(right.title));
+ }
+ return parseDateToMs(right.updatedAt) - parseDateToMs(left.updatedAt);
+ });
+ return sorted;
+}
+
+function normalizeItem(raw, index) {
+ const idFromInput = normalizeText(raw?.id);
+ const title = normalizeText(raw?.title, `Item ${index + 1}`);
+ const id = idFromInput || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || `item-${index + 1}`;
+ return {
+ id,
+ title,
+ description: normalizeText(raw?.description),
+ details: normalizeText(raw?.details),
+ repo: normalizeText(raw?.repo),
+ number: normalizeText(raw?.number),
+ url: normalizeText(raw?.url),
+ labels: normalizeStringArray(raw?.labels),
+ assignees: normalizeStringArray(raw?.assignees),
+ createdAt: normalizeText(raw?.createdAt),
+ updatedAt: normalizeText(raw?.updatedAt),
+ author: normalizeText(raw?.author),
+ };
+}
+
+function getOrCreateBoard(boardId) {
+ if (!storage.boards[boardId]) {
+ storage.boards[boardId] = {
+ id: boardId,
+ title: "Backlog Triage",
+ items: [],
+ decisions: {},
+ workStatus: {},
+ filters: { ...defaultFilters },
+ updatedAt: new Date().toISOString(),
+ };
+ }
+ if (!storage.boards[boardId].workStatus || typeof storage.boards[boardId].workStatus !== "object") {
+ storage.boards[boardId].workStatus = {};
+ }
+ return storage.boards[boardId];
+}
+
+function setBoardItems(board, items, replace = true) {
+ const normalized = Array.isArray(items) ? items.map((item, index) => normalizeItem(item, index)) : [];
+ const repoFromItems = normalized.find((item) => normalizeText(item.repo));
+ if (repoFromItems) {
+ board.repo = repoFromItems.repo;
+ }
+ if (replace) {
+ board.items = normalized;
+ } else {
+ const existingById = new Map(board.items.map((item) => [item.id, item]));
+ for (const item of normalized) {
+ existingById.set(item.id, item);
+ }
+ board.items = [...existingById.values()];
+ }
+ board.updatedAt = new Date().toISOString();
+}
+
+function applyBoardDecision(board, itemId, decision, extra = {}) {
+ if (!decisions.includes(decision)) {
+ throw new Error(`Unsupported decision "${decision}"`);
+ }
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ if (!item) {
+ throw new Error(`Item "${itemId}" not found on board "${board.id}"`);
+ }
+ board.decisions[itemId] = {
+ decision,
+ agent: normalizeText(extra.agent),
+ note: normalizeText(extra.note),
+ at: new Date().toISOString(),
+ };
+ board.updatedAt = new Date().toISOString();
+}
+
+function resetBoardDecisions(board) {
+ board.decisions = {};
+ board.updatedAt = new Date().toISOString();
+}
+
+function buildItemWorkStatus(board, item) {
+ const statuses = [];
+ const assignees = normalizeStringArray(item?.assignees);
+ if (assignees.length > 0) {
+ statuses.push({ label: `Assigned: ${assignees.join(", ")}` });
+ }
+ const decision = board.decisions?.[item.id];
+ const triageAgent = normalizeText(decision?.decision === "assign_agent" ? decision?.agent : "");
+ if (assignees.length === 0 && triageAgent) {
+ statuses.push({ label: `Assigned in triage: ${triageAgent}` });
+ }
+ const work = board.workStatus?.[item.id];
+ if (work?.sessionState === "active") {
+ const sessionName = normalizeText(work.sessionName);
+ statuses.push({ label: sessionName ? `Session active: ${sessionName}` : "Session active" });
+ } else if (work?.sessionState === "starting") {
+ statuses.push({ label: "Session starting" });
+ } else if (work?.sessionState === "requested") {
+ const sessionName = normalizeText(work.sessionName);
+ statuses.push({ label: sessionName ? `Session requested: ${sessionName}` : "Session requested" });
+ }
+ return statuses;
+}
+
+function buildBoardState(board) {
+ const allLabels = [...new Set(board.items.flatMap((item) => (Array.isArray(item.labels) ? item.labels : [])))].sort((a, b) =>
+ a.localeCompare(b),
+ );
+ const hasUnassigned = board.items.some((item) => !Array.isArray(item.assignees) || item.assignees.length === 0);
+ const allAssignees = [
+ ...new Set(board.items.flatMap((item) => (Array.isArray(item.assignees) ? item.assignees : []))),
+ ].sort((a, b) => a.localeCompare(b));
+ if (hasUnassigned) {
+ allAssignees.unshift("unassigned");
+ }
+ const pending = [];
+ const resolved = [];
+ for (const item of board.items) {
+ const itemWithStatus = { ...item, workStatus: buildItemWorkStatus(board, item) };
+ const result = board.decisions[item.id];
+ if (result) {
+ resolved.push({ ...itemWithStatus, result });
+ } else {
+ pending.push(itemWithStatus);
+ }
+ }
+ return {
+ boardId: board.id,
+ title: board.title,
+ repo: normalizeText(board.repo),
+ syncedAt: normalizeText(board.syncedAt),
+ filters: normalizeFilters(board.filters, defaultFilters),
+ availableLabels: allLabels,
+ availableAssignees: allAssignees,
+ pending,
+ resolved,
+ decisionCounts: resolved.reduce((counts, item) => {
+ const key = item.result.decision;
+ counts[key] = (counts[key] || 0) + 1;
+ return counts;
+ }, {}),
+ updatedAt: board.updatedAt,
+ };
+}
+
+function buildIssueDetails(issue) {
+ const parts = [];
+ const author = normalizeText(issue.author?.login);
+ if (author) {
+ parts.push(`Author: ${author}`);
+ }
+ if (normalizeText(issue.createdAt)) {
+ parts.push(`Created: ${normalizeText(issue.createdAt).slice(0, 10)}`);
+ }
+ if (normalizeText(issue.updatedAt)) {
+ parts.push(`Updated: ${normalizeText(issue.updatedAt).slice(0, 10)}`);
+ }
+ return parts.join(" | ");
+}
+
+function buildIssueDescription(issue) {
+ const body = normalizeText(issue.body);
+ if (!body) {
+ return "";
+ }
+ const normalized = body
+ .replace(/\r/g, "")
+ .replace(/!\[.*?\]\(.*?\)/g, "")
+ .replace(/\n{2,}/g, "\n\n")
+ .trim();
+ if (normalized.length <= 2200) {
+ return normalized;
+ }
+ return `${normalized.slice(0, 2197).trimEnd()}...`;
+}
+
+async function runGhJson(args, cwd) {
+ const result = await execFileAsync("gh", args, {
+ cwd,
+ windowsHide: true,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ return JSON.parse(result.stdout);
+}
+
+async function runGh(args, cwd) {
+ const result = await execFileAsync("gh", args, {
+ cwd,
+ windowsHide: true,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ return result.stdout;
+}
+
+async function closeGithubIssue(board, item, note) {
+ const issueNumber = normalizeText(item?.number);
+ const repo = normalizeText(board?.repo || item?.repo);
+ if (!issueNumber || !repo) {
+ throw new Error("Cannot close issue on GitHub because repo or issue number is missing.");
+ }
+ const args = ["issue", "close", issueNumber, "--repo", repo];
+ const comment = normalizeText(note);
+ if (comment) {
+ args.push("--comment", comment);
+ }
+ try {
+ await runGh(args, activeSession?.workspacePath || process.cwd());
+ } catch (error) {
+ const stderr = normalizeText(error?.stderr || "");
+ if (stderr.toLowerCase().includes("already closed")) {
+ return;
+ }
+ throw new Error(stderr || `Failed to close issue #${issueNumber} in ${repo}.`);
+ }
+}
+
+async function commentGithubIssue(board, item, note) {
+ const repo = normalizeText(board?.repo || item?.repo);
+ const issueNumber = extractIssueNumber(item);
+ const comment = normalizeText(note);
+ if (!repo || !issueNumber) {
+ throw new Error("Cannot comment on issue because repo or issue number is missing.");
+ }
+ if (!comment) {
+ return;
+ }
+ try {
+ await runGh(["issue", "comment", issueNumber, "--repo", repo, "--body", comment], activeSession?.workspacePath || process.cwd());
+ } catch (error) {
+ const stderr = normalizeText(error?.stderr || "");
+ throw new Error(stderr || `Failed to comment on issue #${issueNumber} in ${repo}.`);
+ }
+}
+
+function extractIssueNumber(item) {
+ const explicit = normalizeText(item?.number);
+ if (/^\d+$/.test(explicit)) {
+ return explicit;
+ }
+ const idMatch = normalizeText(item?.id).match(/^issue-(\d+)$/i);
+ if (idMatch) {
+ return idMatch[1];
+ }
+ const titleMatch = normalizeText(item?.title).match(/^#(\d+)\b/);
+ if (titleMatch) {
+ return titleMatch[1];
+ }
+ return "";
+}
+
+async function startImplementationSession(board, item, agent, note) {
+ if (!activeSession) {
+ throw new Error("Copilot session is unavailable for starting implementation sessions.");
+ }
+ const repo = normalizeText(board?.repo || item?.repo);
+ const issueNumber = extractIssueNumber(item);
+ if (!repo || !issueNumber) {
+ throw new Error("Cannot start implementation session because repo or issue number is missing.");
+ }
+ const rawTitle = normalizeText(item?.title);
+ const issueTitle = rawTitle.replace(new RegExp(`^#${issueNumber}\\s*`), "").trim() || rawTitle || `Issue #${issueNumber}`;
+ const summary = normalizeText(item?.description);
+ const kickoffLines = [
+ `Implement GitHub issue #${issueNumber}: ${issueTitle}`,
+ `Repository: ${repo}`,
+ ];
+ if (summary) {
+ kickoffLines.push(`Context: ${summary}`);
+ }
+ if (normalizeText(note)) {
+ kickoffLines.push(`Triage note: ${normalizeText(note)}`);
+ }
+ kickoffLines.push(
+ "Deliver a complete fix with code changes, run relevant validation, and open a PR-ready branch state with a concise summary.",
+ );
+ const kickoffPrompt = kickoffLines.join("\n");
+ const sessionRequest = [
+ `Create a new implementation project session for GitHub issue #${issueNumber} in ${repo}.`,
+ "Use the open_issue_session tool with these exact fields:",
+ `- repo_full_name: ${JSON.stringify(repo)}`,
+ `- issue_number: ${Number(issueNumber)}`,
+ `- issue_title: ${JSON.stringify(issueTitle)}`,
+ '- kickoff_mode: "autopilot"',
+ '- coordinate_with_creator: true',
+ '- notify_on_idle: "once"',
+ `- kickoff_prompt: ${JSON.stringify(kickoffPrompt)}`,
+ "",
+ "After the tool call succeeds, reply with a one-line confirmation including the new session name.",
+ ].join("\n");
+ await activeSession.send({
+ prompt: sessionRequest,
+ mode: "immediate",
+ displayPrompt: `Start implementation session for #${issueNumber}`,
+ });
+ return {
+ sessionState: "requested",
+ sessionName: `Issue #${issueNumber}`,
+ issueNumber,
+ agent: normalizeText(agent),
+ requestedAt: new Date().toISOString(),
+ };
+}
+
+function pruneDecisionsForCurrentItems(board) {
+ const currentIds = new Set(board.items.map((item) => item.id));
+ for (const itemId of Object.keys(board.decisions)) {
+ if (!currentIds.has(itemId)) {
+ delete board.decisions[itemId];
+ }
+ }
+ if (board.workStatus && typeof board.workStatus === "object") {
+ for (const itemId of Object.keys(board.workStatus)) {
+ if (!currentIds.has(itemId)) {
+ delete board.workStatus[itemId];
+ }
+ }
+ }
+}
+
+async function syncBoardFromRepo(board, filtersInput) {
+ const workspacePath = activeSession?.workspacePath;
+ let repo = normalizeText(board.repo);
+ if (!repo && workspacePath) {
+ const repoData = await runGhJson(["repo", "view", "--json", "nameWithOwner"], workspacePath);
+ repo = normalizeText(repoData?.nameWithOwner);
+ }
+ if (!repo) {
+ throw new Error("Repository is not configured. Open the canvas with a repo or call sync_from_repo with { repo: \"owner/name\" }.");
+ }
+ const filters = normalizeFilters(filtersInput, board.filters || defaultFilters);
+
+ const issues = await runGhJson(
+ [
+ "issue",
+ "list",
+ "--repo",
+ repo,
+ "--state",
+ "open",
+ "--limit",
+ String(MAX_SYNC_ISSUES),
+ "--json",
+ "number,title,url,labels,assignees,createdAt,updatedAt,author,body",
+ ],
+ workspacePath || process.cwd(),
+ );
+
+ const filteredIssues = Array.isArray(issues) ? sortIssues(issues.filter((issue) => issueMatchesFilters(issue, filters)), filters.sortBy) : [];
+ const items = filteredIssues.map((issue) => ({
+ id: `issue-${issue.number}`,
+ title: `#${issue.number} ${normalizeText(issue.title, "Untitled issue")}`,
+ description: buildIssueDescription(issue),
+ details: buildIssueDetails(issue),
+ repo,
+ number: String(issue.number),
+ url: normalizeText(issue.url),
+ labels: Array.isArray(issue.labels) ? issue.labels.map((label) => normalizeText(label?.name)).filter(Boolean) : [],
+ assignees: Array.isArray(issue.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login)).filter(Boolean) : [],
+ createdAt: normalizeText(issue.createdAt),
+ updatedAt: normalizeText(issue.updatedAt),
+ author: normalizeText(issue.author?.login),
+ }));
+
+ setBoardItems(board, items, true);
+ pruneDecisionsForCurrentItems(board);
+ board.source = "repo";
+ board.repo = repo;
+ board.filters = filters;
+ board.syncedAt = new Date().toISOString();
+}
+
+function renderHtml(instanceId, title) {
+ const safeTitle = escapeHtml(title || "Backlog Swipe Triage");
+ const safeInstanceId = escapeHtml(instanceId || "default");
+ return `
+
+
+
+
+ ${safeTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All labels
+
+
+
+
+
+
+ All assignees
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Applying action…
+
+
+
+
Swipe-up quick responses
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Swipe mappings: left=close, right=assign agent,
+ up=more options, down=ignore. Arrow keys work too.
+
+
+
+
+`;
+}
+
+function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let totalBytes = 0;
+ let settled = false;
+ req.on("data", (chunk) => {
+ if (settled) {
+ return;
+ }
+ totalBytes += chunk.length;
+ if (totalBytes > maxBytes) {
+ settled = true;
+ const error = new Error(`Request body exceeds ${maxBytes} bytes.`);
+ error.statusCode = 413;
+ req.destroy(error);
+ reject(error);
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => {
+ if (settled) {
+ return;
+ }
+ const raw = Buffer.concat(chunks).toString("utf8");
+ if (!raw) {
+ resolve({});
+ return;
+ }
+ try {
+ resolve(JSON.parse(raw));
+ } catch (error) {
+ error.statusCode = 400;
+ reject(error);
+ }
+ });
+ req.on("error", (error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(error);
+ });
+ });
+}
+
+async function handleServerRequest(instanceId, req, res) {
+ const entry = servers.get(instanceId);
+ if (!entry) {
+ res.statusCode = 404;
+ res.end("Instance not found");
+ return;
+ }
+
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(entry.boardId);
+ board.title = entry.title;
+
+ if (req.method === "GET" && req.url === "/") {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderHtml(instanceId, board.title));
+ return;
+ }
+
+ if (req.method === "GET" && req.url === "/state") {
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ return;
+ }
+
+ if (req.method === "POST" && req.url === "/sync") {
+ try {
+ const payload = await readJson(req);
+ const repo = normalizeText(payload?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ if (payload?.resetDecisions === true) {
+ resetBoardDecisions(board);
+ }
+ await syncBoardFromRepo(board, payload?.filters);
+ await persistStorage();
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ } catch (error) {
+ res.statusCode = error?.statusCode || 500;
+ res.end(error instanceof Error ? error.message : "Failed to sync from repo");
+ }
+ return;
+ }
+
+ if (req.method === "POST" && req.url === "/decision") {
+ let payload;
+ try {
+ payload = await readJson(req);
+ } catch (error) {
+ res.statusCode = error?.statusCode || 400;
+ res.end(
+ error?.statusCode === 413
+ ? "Request body too large"
+ : "Invalid JSON payload",
+ );
+ return;
+ }
+
+ const itemId = normalizeText(payload?.itemId);
+ const decision = normalizeText(payload?.decision);
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ if (!itemId || !decision) {
+ res.statusCode = 400;
+ res.end("itemId and decision are required");
+ return;
+ }
+ if (!item) {
+ res.statusCode = 404;
+ res.end(`Item "${itemId}" not found`);
+ return;
+ }
+ if (decision === "close") {
+ await closeGithubIssue(board, item, payload?.note);
+ }
+ if (payload?.quickResponse === true && decision !== "close" && normalizeText(payload?.note)) {
+ await commentGithubIssue(board, item, payload?.note);
+ }
+ if (decision === "assign_agent") {
+ const sessionStatus = await startImplementationSession(board, item, payload?.agent, payload?.note);
+ board.workStatus[itemId] = {
+ ...sessionStatus,
+ agent: normalizeText(payload?.agent),
+ };
+ }
+
+ applyBoardDecision(board, itemId, decision, {
+ agent: payload?.agent,
+ note: payload?.note,
+ });
+ await persistStorage();
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ return;
+ }
+
+ res.statusCode = 404;
+ res.end("Not found");
+}
+
+async function startServer(instanceId) {
+ const server = createServer((req, res) => {
+ handleServerRequest(instanceId, req, res).catch((error) => {
+ if (res.headersSent) {
+ res.end();
+ return;
+ }
+ res.statusCode = error?.statusCode || 500;
+ res.end(error instanceof Error ? error.message : "Internal server error");
+ });
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ return { server, url: `http://127.0.0.1:${port}/` };
+}
+
+const session = await joinSession({
+ canvases: [
+ createCanvas({
+ id: "backlog-swipe-triage",
+ displayName: "Backlog Swipe Triage",
+ description: "Tinder-style backlog triage with swipe directions for assign, needs info, not now, close, and ignore.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string", minLength: 1 },
+ syncFromRepo: { type: "boolean" },
+ repo: { type: "string", minLength: 1 },
+ filters: filterSchema,
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ title: { type: "string" },
+ details: { type: "string" },
+ repo: { type: "string" },
+ number: { type: "string" },
+ url: { type: "string" },
+ },
+ required: ["title"],
+ additionalProperties: true,
+ },
+ },
+ },
+ additionalProperties: false,
+ },
+ actions: [
+ {
+ name: "sync_from_repo",
+ description: "Load open issues from the current repository into the triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string" },
+ repo: { type: "string", minLength: 1 },
+ filters: filterSchema,
+ },
+ required: ["boardId"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ const title = normalizeText(ctx.input?.title);
+ if (title) {
+ board.title = title;
+ }
+ const repo = normalizeText(ctx.input?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ await syncBoardFromRepo(board, ctx.input?.filters);
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "seed_backlog",
+ description: "Seed or update backlog items for a triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string" },
+ replace: { type: "boolean" },
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ title: { type: "string" },
+ details: { type: "string" },
+ repo: { type: "string" },
+ number: { type: "string" },
+ url: { type: "string" },
+ },
+ required: ["title"],
+ additionalProperties: true,
+ },
+ },
+ },
+ required: ["boardId", "items"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const boardId = normalizeText(ctx.input?.boardId, "default");
+ const board = getOrCreateBoard(boardId);
+ const title = normalizeText(ctx.input?.title);
+ if (title) {
+ board.title = title;
+ }
+ setBoardItems(board, ctx.input?.items, ctx.input?.replace !== false);
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "apply_decision",
+ description: "Apply a triage decision to a backlog item.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ itemId: { type: "string", minLength: 1 },
+ decision: { type: "string", enum: decisions },
+ agent: { type: "string" },
+ note: { type: "string" },
+ commentOnIssue: { type: "boolean" },
+ },
+ required: ["boardId", "itemId", "decision"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ const itemId = normalizeText(ctx.input?.itemId);
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ const decision = normalizeText(ctx.input?.decision);
+ if (!item) {
+ throw new Error(`Item "${itemId}" not found`);
+ }
+ if (decision === "close") {
+ await closeGithubIssue(board, item, ctx.input?.note);
+ }
+ if (ctx.input?.commentOnIssue === true && decision !== "close" && normalizeText(ctx.input?.note)) {
+ await commentGithubIssue(board, item, ctx.input?.note);
+ }
+ if (decision === "assign_agent") {
+ const sessionStatus = await startImplementationSession(board, item, ctx.input?.agent, ctx.input?.note);
+ board.workStatus[itemId] = {
+ ...sessionStatus,
+ agent: normalizeText(ctx.input?.agent),
+ };
+ }
+ applyBoardDecision(board, itemId, decision, {
+ agent: ctx.input?.agent,
+ note: ctx.input?.note,
+ });
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "get_board",
+ description: "Get pending and triaged items for a triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ },
+ required: ["boardId"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ return buildBoardState(board);
+ },
+ },
+ ],
+ open: async (ctx) => {
+ await ensureStorageLoaded();
+ const boardId = normalizeText(ctx.input?.boardId, "default");
+ const board = getOrCreateBoard(boardId);
+ const title = normalizeText(ctx.input?.title, board.title || "Backlog Triage");
+ board.title = title;
+ const repo = normalizeText(ctx.input?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ if (ctx.input?.filters && typeof ctx.input.filters === "object") {
+ board.filters = normalizeFilters(ctx.input.filters, board.filters || defaultFilters);
+ } else if (!board.filters) {
+ board.filters = { ...defaultFilters };
+ }
+ const syncFromRepo = ctx.input?.syncFromRepo !== false;
+ if (Array.isArray(ctx.input?.items) && ctx.input.items.length > 0) {
+ setBoardItems(board, ctx.input.items, true);
+ await persistStorage();
+ } else if (syncFromRepo) {
+ await syncBoardFromRepo(board, board.filters);
+ await persistStorage();
+ }
+
+ let entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ entry = await startServer(ctx.instanceId);
+ servers.set(ctx.instanceId, entry);
+ }
+ entry.boardId = boardId;
+ entry.title = title;
+ return {
+ title,
+ status: "Swipe to triage backlog",
+ url: entry.url,
+ };
+ },
+ onClose: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (entry) {
+ servers.delete(ctx.instanceId);
+ await new Promise((resolve) => entry.server.close(() => resolve()));
+ }
+ },
+ }),
+ ],
+});
+activeSession = session;
diff --git a/extensions/backlog-swipe-triage/package-lock.json b/extensions/backlog-swipe-triage/package-lock.json
new file mode 100644
index 00000000..38325be1
--- /dev/null
+++ b/extensions/backlog-swipe-triage/package-lock.json
@@ -0,0 +1,218 @@
+{
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+ },
+ "node_modules/@github/copilot": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz",
+ "integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "detect-libc": "^2.1.2"
+ },
+ "bin": {
+ "copilot": "npm-loader.js"
+ },
+ "optionalDependencies": {
+ "@github/copilot-darwin-arm64": "1.0.61",
+ "@github/copilot-darwin-x64": "1.0.61",
+ "@github/copilot-linux-arm64": "1.0.61",
+ "@github/copilot-linux-x64": "1.0.61",
+ "@github/copilot-linuxmusl-arm64": "1.0.61",
+ "@github/copilot-linuxmusl-x64": "1.0.61",
+ "@github/copilot-win32-arm64": "1.0.61",
+ "@github/copilot-win32-x64": "1.0.61"
+ }
+ },
+ "node_modules/@github/copilot-darwin-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz",
+ "integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-darwin-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz",
+ "integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz",
+ "integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz",
+ "integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz",
+ "integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz",
+ "integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-sdk": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
+ "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
+ "license": "MIT",
+ "dependencies": {
+ "@github/copilot": "^1.0.61",
+ "vscode-jsonrpc": "^8.2.1",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@github/copilot-win32-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz",
+ "integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-arm64": "copilot.exe"
+ }
+ },
+ "node_modules/@github/copilot-win32-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz",
+ "integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-x64": "copilot.exe"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
+ "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/extensions/backlog-swipe-triage/package.json b/extensions/backlog-swipe-triage/package.json
new file mode 100644
index 00000000..15a0677a
--- /dev/null
+++ b/extensions/backlog-swipe-triage/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "extension.mjs",
+ "description": "Swipe-driven backlog triage canvas for reviewing open issues and assigning implementation work.",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+}
diff --git a/extensions/color-orb/assets/preview.png b/extensions/color-orb/assets/preview.png
new file mode 100644
index 00000000..3294126a
Binary files /dev/null and b/extensions/color-orb/assets/preview.png differ
diff --git a/extensions/diagram-viewer/assets/preview.png b/extensions/diagram-viewer/assets/preview.png
new file mode 100644
index 00000000..bf09d1ab
Binary files /dev/null and b/extensions/diagram-viewer/assets/preview.png differ
diff --git a/extensions/external-assets/coffilot-preview.png b/extensions/external-assets/coffilot-preview.png
new file mode 100644
index 00000000..544fec6c
Binary files /dev/null and b/extensions/external-assets/coffilot-preview.png differ
diff --git a/extensions/external.json b/extensions/external.json
new file mode 100644
index 00000000..5ce10ab6
--- /dev/null
+++ b/extensions/external.json
@@ -0,0 +1,10 @@
+[
+ {
+ "id": "coffilot",
+ "name": "Coffilot",
+ "description": "Java-focused Copilot canvas extension from jdubois.",
+ "installUrl": "https://github.com/jdubois/coffilot",
+ "sourceUrl": "https://github.com/jdubois/coffilot",
+ "imagePath": "extensions/external-assets/coffilot-preview.png"
+ }
+]
diff --git a/extensions/feedback-themes/assets/preview.png b/extensions/feedback-themes/assets/preview.png
new file mode 100644
index 00000000..deeeee87
Binary files /dev/null and b/extensions/feedback-themes/assets/preview.png differ
diff --git a/extensions/gesture-review/assets/preview.png b/extensions/gesture-review/assets/preview.png
new file mode 100644
index 00000000..61268730
Binary files /dev/null and b/extensions/gesture-review/assets/preview.png differ
diff --git a/extensions/release-notes-showcase/README.md b/extensions/release-notes-showcase/README.md
new file mode 100644
index 00000000..6192daf5
--- /dev/null
+++ b/extensions/release-notes-showcase/README.md
@@ -0,0 +1,7 @@
+# Release Notes Showcase
+
+Interactive canvas for composing, reviewing, and exporting release notes content.
+
+## Assets
+
+- `assets/preview.png` — screenshot preview used by the extensions gallery.
diff --git a/extensions/release-notes-showcase/assets/preview.png b/extensions/release-notes-showcase/assets/preview.png
new file mode 100644
index 00000000..c1818140
Binary files /dev/null and b/extensions/release-notes-showcase/assets/preview.png differ
diff --git a/extensions/release-notes-showcase/extension.mjs b/extensions/release-notes-showcase/extension.mjs
new file mode 100644
index 00000000..26306a7d
--- /dev/null
+++ b/extensions/release-notes-showcase/extension.mjs
@@ -0,0 +1,7 @@
+import { joinSession } from "@github/copilot-sdk/extension";
+
+import { releaseNotesShowcaseCanvas } from "./releaseNotesShowcase.mjs";
+
+await joinSession({
+ canvases: [releaseNotesShowcaseCanvas],
+});
diff --git a/extensions/release-notes-showcase/package-lock.json b/extensions/release-notes-showcase/package-lock.json
new file mode 100644
index 00000000..09f84a79
--- /dev/null
+++ b/extensions/release-notes-showcase/package-lock.json
@@ -0,0 +1,286 @@
+{
+ "name": "release-notes-showcase",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "release-notes-showcase",
+ "version": "1.0.0",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+ },
+ "node_modules/@github/copilot": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz",
+ "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "detect-libc": "^2.1.2",
+ "os-theme": "^0.0.8"
+ },
+ "bin": {
+ "copilot": "npm-loader.js"
+ },
+ "optionalDependencies": {
+ "@github/copilot-darwin-arm64": "1.0.63",
+ "@github/copilot-darwin-x64": "1.0.63",
+ "@github/copilot-linux-arm64": "1.0.63",
+ "@github/copilot-linux-x64": "1.0.63",
+ "@github/copilot-linuxmusl-arm64": "1.0.63",
+ "@github/copilot-linuxmusl-x64": "1.0.63",
+ "@github/copilot-win32-arm64": "1.0.63",
+ "@github/copilot-win32-x64": "1.0.63"
+ }
+ },
+ "node_modules/@github/copilot-darwin-arm64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz",
+ "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-darwin-x64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz",
+ "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-arm64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz",
+ "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-x64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz",
+ "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-arm64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz",
+ "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-x64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz",
+ "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-sdk": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
+ "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
+ "license": "MIT",
+ "dependencies": {
+ "@github/copilot": "^1.0.61",
+ "vscode-jsonrpc": "^8.2.1",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@github/copilot-win32-arm64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz",
+ "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-arm64": "copilot.exe"
+ }
+ },
+ "node_modules/@github/copilot-win32-x64": {
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz",
+ "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-x64": "copilot.exe"
+ }
+ },
+ "node_modules/@os-theme/darwin-arm64": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz",
+ "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@os-theme/linux-x64": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz",
+ "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@os-theme/win32-x64": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz",
+ "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/os-theme": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz",
+ "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "@os-theme/darwin-arm64": "0.0.8",
+ "@os-theme/linux-x64": "0.0.8",
+ "@os-theme/win32-x64": "0.0.8"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
+ "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/extensions/release-notes-showcase/package.json b/extensions/release-notes-showcase/package.json
new file mode 100644
index 00000000..0bb276cf
--- /dev/null
+++ b/extensions/release-notes-showcase/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "release-notes-showcase",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "extension.mjs",
+ "description": "Release notes canvas for building polished release communications and exports.",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+}
diff --git a/extensions/release-notes-showcase/releaseNotesShowcase.mjs b/extensions/release-notes-showcase/releaseNotesShowcase.mjs
new file mode 100644
index 00000000..d4a7fa28
--- /dev/null
+++ b/extensions/release-notes-showcase/releaseNotesShowcase.mjs
@@ -0,0 +1,2348 @@
+import { spawnSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { createServer } from "node:http";
+import { homedir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { CanvasError, createCanvas } from "@github/copilot-sdk/extension";
+
+const servers = new Map();
+
+const CANVAS_ID = "release-notes-showcase";
+const CANVAS_TITLE = "Release Notes Showcase";
+
+const releaseNotesInputSchema = {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ releaseName: { type: "string" },
+ version: { type: "string" },
+ releaseDate: { type: "string" },
+ tagline: { type: "string" },
+ summary: { type: "string" },
+ emailSubject: { type: "string" },
+ emailPreheader: { type: "string" },
+ heroStats: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ label: { type: "string" },
+ value: { type: "string" },
+ },
+ required: ["label", "value"],
+ },
+ },
+ sections: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ title: { type: "string" },
+ kind: {
+ type: "string",
+ enum: ["feature", "improvement", "quality"],
+ },
+ summary: { type: "string" },
+ metric: { type: "string" },
+ bullets: {
+ type: "array",
+ items: { type: "string" },
+ },
+ },
+ required: ["title", "summary"],
+ },
+ },
+ contributors: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ name: { type: "string" },
+ githubHandle: { type: "string" },
+ avatarUrl: { type: "string" },
+ profileUrl: { type: "string" },
+ area: { type: "string" },
+ summary: { type: "string" },
+ },
+ required: ["name"],
+ },
+ },
+ communityThanks: {
+ type: "array",
+ items: { type: "string" },
+ },
+ otherChanges: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ label: { type: "string" },
+ text: { type: "string" },
+ },
+ required: ["text"],
+ },
+ },
+ callToAction: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ label: { type: "string" },
+ url: { type: "string" },
+ },
+ required: ["label", "url"],
+ },
+ },
+};
+
+const exportInputSchema = {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ format: {
+ type: "string",
+ enum: ["html", "text", "both"],
+ },
+ },
+};
+
+let repositoryContext = resolveRepositoryContext("", "");
+let sampleRelease = Object.freeze(buildDefaultRelease(repositoryContext));
+
+function buildDefaultRelease(context) {
+ const releaseName = context.displayName;
+ const version = "vNext";
+ const releaseDate = new Intl.DateTimeFormat("en-US", {
+ month: "long",
+ year: "numeric",
+ }).format(new Date());
+
+ return {
+ releaseName,
+ version,
+ releaseDate,
+ tagline: `No release data loaded yet for ${releaseName}.`,
+ summary:
+ "Use Release source to load a tag or draft unreleased changes from repository history.",
+ emailSubject: `${releaseName} ${version} - release highlights`,
+ emailPreheader: `Release draft for ${releaseName}.`,
+ heroStats: [
+ { label: "Commits", value: "00" },
+ { label: "Merged PRs", value: "00" },
+ { label: "Closed issues", value: "00" },
+ { label: "Repository", value: context.repoSlug },
+ ],
+ sections: [],
+ contributors: [],
+ communityThanks: [],
+ otherChanges: [],
+ callToAction: {
+ label: "View repository",
+ url: context.repoUrl,
+ },
+ };
+}
+
+function resolveRepositoryContext(preferredWorkingDirectory, sessionId) {
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
+ const sessionWorkingDirectory = readSessionWorkingDirectoryFromMetadata(sessionId);
+ const repoRoot =
+ findRepositoryRoot(preferredWorkingDirectory ?? "") ||
+ findRepositoryRoot(sessionWorkingDirectory) ||
+ findRepositoryRoot(process.cwd()) ||
+ findRepositoryRoot(extensionDir);
+ const repoName = repoRoot ? basename(repoRoot) : "current-repository";
+ const remoteUrl = repoRoot ? readRemoteOrigin(repoRoot) : "";
+ const parsed = parseRepositorySlug(remoteUrl);
+ const repoSlug = parsed ?? repoName;
+ const slugLeaf = repoSlug.split("/").at(-1) || repoName;
+ const displayName = humanizeRepoName(slugLeaf);
+ const repoUrl = parsed ? `https://github.com/${parsed}` : "https://github.com/";
+
+ return {
+ repoRoot,
+ repoSlug,
+ displayName,
+ repoUrl,
+ };
+}
+
+function findRepositoryRoot(startPath) {
+ if (!startPath) {
+ return "";
+ }
+
+ let current = startPath;
+
+ while (true) {
+ if (existsSync(join(current, ".git"))) {
+ return current;
+ }
+
+ const parent = dirname(current);
+ if (parent === current) {
+ return "";
+ }
+
+ current = parent;
+ }
+}
+
+function readRemoteOrigin(repoRoot) {
+ const result = spawnSync("git", ["-C", repoRoot, "config", "--get", "remote.origin.url"], {
+ encoding: "utf8",
+ });
+
+ if (result.status !== 0 || typeof result.stdout !== "string") {
+ return "";
+ }
+
+ return result.stdout.trim();
+}
+
+function parseRepositorySlug(remoteUrl) {
+ if (!remoteUrl) {
+ return "";
+ }
+
+ const httpsMatch = remoteUrl.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i);
+ if (httpsMatch?.[1]) {
+ return httpsMatch[1];
+ }
+
+ const sshMatch = remoteUrl.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
+ if (sshMatch?.[1]) {
+ return sshMatch[1];
+ }
+
+ return "";
+}
+
+function humanizeRepoName(value) {
+ return value
+ .replace(/[-_]+/g, " ")
+ .trim()
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+}
+
+function readSessionWorkingDirectoryFromMetadata(sessionId) {
+ const resolvedSessionId =
+ pickString(sessionId, "") ||
+ pickString(process.env.SESSION_ID, "") ||
+ pickString(process.env.COPILOT_AGENT_SESSION_ID, "");
+ if (!resolvedSessionId) {
+ return "";
+ }
+
+ const metadataPath = join(
+ homedir(),
+ ".copilot",
+ "session-state",
+ resolvedSessionId,
+ "vscode.metadata.json",
+ );
+ const workspacePath = join(
+ homedir(),
+ ".copilot",
+ "session-state",
+ resolvedSessionId,
+ "workspace.yaml",
+ );
+
+ const candidatePaths = [metadataPath, workspacePath];
+ for (const path of candidatePaths) {
+ if (!existsSync(path)) {
+ continue;
+ }
+
+ let text = "";
+ try {
+ text = readFileSync(path, "utf8");
+ } catch {
+ continue;
+ }
+
+ const match = text.match(/^cwd:\s*(.+)$/m);
+ if (match?.[1]?.trim()) {
+ return match[1].trim();
+ }
+ }
+
+ return "";
+}
+
+function runGit(repoRoot, args) {
+ if (!repoRoot) {
+ return "";
+ }
+
+ const result = spawnSync("git", ["-C", repoRoot, ...args], {
+ encoding: "utf8",
+ });
+
+ if (result.status !== 0 || typeof result.stdout !== "string") {
+ return "";
+ }
+
+ return result.stdout.trim();
+}
+
+function listReleaseTags(repoRoot) {
+ const output = runGit(repoRoot, ["tag", "--sort=-creatordate"]);
+ if (!output) {
+ return [];
+ }
+
+ return output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+function readTagDate(repoRoot, tag) {
+ const output = runGit(repoRoot, ["log", "-1", "--date=short", "--format=%ad", tag]);
+ return output || "";
+}
+
+function readCommitSummaries(repoRoot, rangeExpr) {
+ const output = runGit(repoRoot, [
+ "log",
+ "--max-count=250",
+ "--pretty=format:%s%x1f%an",
+ rangeExpr,
+ ]);
+ if (!output) {
+ return [];
+ }
+
+ return output
+ .split(/\r?\n/)
+ .map((line) => line.split("\x1f"))
+ .filter((parts) => parts.length >= 2)
+ .map(([subject, author]) => ({
+ subject: cleanCommitSubject(subject),
+ author: pickString(author, "Contributor"),
+ }))
+ .filter((entry) => entry.subject);
+}
+
+function cleanCommitSubject(value) {
+ return pickString(value, "")
+ .replace(/^\w+(\([^)]+\))?!?:\s*/i, "")
+ .replace(/\s+\(#\d+\)\s*$/u, "")
+ .trim();
+}
+
+function classifyCommit(subject) {
+ const lower = subject.toLowerCase();
+ if (/^(feat|feature)\b/.test(lower) || /add|introduce|support|new/.test(lower)) {
+ return "feature";
+ }
+
+ if (/^(fix|perf|refactor)\b/.test(lower) || /improv|stabil|reliab|optim/.test(lower)) {
+ return "improvement";
+ }
+
+ return "quality";
+}
+
+function toReleaseStateFromCommits(context, commits, options) {
+ const releaseName = context.displayName;
+ const version = options.version;
+ const releaseDate = options.releaseDate;
+ const commitCount = commits.length;
+ const mergedPulls = Array.isArray(options.mergedPulls) ? options.mergedPulls : [];
+ const closedIssues = Array.isArray(options.closedIssues) ? options.closedIssues : [];
+
+ if (commitCount === 0) {
+ const emptyState = buildDefaultRelease(context);
+ return {
+ ...emptyState,
+ releaseName,
+ version,
+ releaseDate,
+ tagline: `No commit changes were detected for ${options.rangeLabel}.`,
+ summary: `There are no commits in ${options.rangeLabel}, so this draft starts from the repository template.`,
+ emailSubject: `${releaseName} ${version} - release highlights`,
+ emailPreheader: `No commit changes detected for ${options.rangeLabel}.`,
+ callToAction: {
+ label: options.callToActionLabel,
+ url: options.callToActionUrl,
+ },
+ };
+ }
+
+ const buckets = {
+ feature: [],
+ improvement: [],
+ quality: [],
+ };
+
+ const contributorCounts = new Map();
+ for (const commit of commits) {
+ const kind = classifyCommit(commit.subject);
+ buckets[kind].push(commit.subject);
+ contributorCounts.set(commit.author, (contributorCounts.get(commit.author) ?? 0) + 1);
+ }
+
+ const sections = [];
+ if (mergedPulls.length > 0) {
+ sections.push({
+ title: "Merged pull requests",
+ kind: "feature",
+ summary: `Pull requests merged since ${options.sinceLabel}.`,
+ metric: `${mergedPulls.length} merged`,
+ bullets: mergedPulls.slice(0, 6).map((pull) => `#${pull.number} ${pull.title}`),
+ });
+ }
+ for (const kind of ["feature", "improvement", "quality"]) {
+ const entries = buckets[kind];
+ if (entries.length === 0) {
+ continue;
+ }
+
+ const kindTitle =
+ kind === "feature"
+ ? "Feature work shipped"
+ : kind === "improvement"
+ ? "Improvements and fixes"
+ : "Quality and maintenance updates";
+ const kindSummary =
+ kind === "feature"
+ ? "New capabilities and user-facing improvements landed in this release."
+ : kind === "improvement"
+ ? "Stability, performance, and reliability updates were delivered."
+ : "Foundational cleanup and maintenance work strengthened the codebase.";
+
+ sections.push({
+ title: kindTitle,
+ kind,
+ summary: kindSummary,
+ metric: `${entries.length} commits`,
+ bullets: entries.slice(0, 6),
+ });
+ }
+
+ const sortedContributors = [...contributorCounts.entries()]
+ .sort((left, right) => right[1] - left[1])
+ .slice(0, 6);
+
+ const contributors = sortedContributors.map(([name, count]) => ({
+ name,
+ githubHandle: "",
+ avatarUrl: "",
+ profileUrl: context.repoUrl,
+ area: count === 1 ? "1 commit" : `${count} commits`,
+ summary: `Contributed ${count} change${count === 1 ? "" : "s"} in ${options.rangeLabel}.`,
+ }));
+
+ const otherChanges = commits.slice(0, 7).map((commit) => ({
+ label: classifyCommit(commit.subject),
+ text: commit.subject,
+ }));
+ if (closedIssues.length > 0) {
+ otherChanges.unshift(
+ ...closedIssues.slice(0, 6).map((issue) => ({
+ label: `Issue #${issue.number}`,
+ text: issue.title,
+ })),
+ );
+ }
+
+ const featureCount = buckets.feature.length;
+
+ return {
+ releaseName,
+ version,
+ releaseDate,
+ tagline: `${commitCount} commits, ${mergedPulls.length} merged PRs, and ${closedIssues.length} closed issues since ${options.sinceLabel}.`,
+ summary: `This draft combines git history with merged pull requests and closed issues since ${options.sinceLabel}.`,
+ emailSubject: `${releaseName} ${version} - release highlights`,
+ emailPreheader: `${commitCount} commits, ${mergedPulls.length} merged PRs, and ${closedIssues.length} closed issues summarized from ${options.rangeLabel}.`,
+ heroStats: [
+ { label: "Commits", value: padCount(commitCount) },
+ { label: "Merged PRs", value: padCount(mergedPulls.length) },
+ { label: "Closed issues", value: padCount(closedIssues.length) },
+ { label: "Features", value: padCount(featureCount) },
+ ],
+ sections: sections.length > 0 ? sections : buildDefaultRelease(context).sections,
+ contributors: contributors.length > 0 ? contributors : buildDefaultRelease(context).contributors,
+ communityThanks: [],
+ otherChanges,
+ callToAction: {
+ label: options.callToActionLabel,
+ url: options.callToActionUrl,
+ },
+ };
+}
+
+function getGitHubToken() {
+ const direct = pickString(process.env.GITHUB_TOKEN, "");
+ if (direct) {
+ return direct;
+ }
+
+ const key = Object.keys(process.env).find((name) =>
+ name.startsWith("COPILOT_GH_ACCOUNT_github_2E_com_"),
+ );
+ return key ? pickString(process.env[key], "") : "";
+}
+
+async function fetchGithubJson(url) {
+ const headers = {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "release-notes-showcase",
+ };
+ const token = getGitHubToken();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const response = await fetch(url, { headers });
+ if (!response.ok) {
+ return [];
+ }
+
+ const payload = await response.json();
+ return Array.isArray(payload) ? payload : [];
+}
+
+function normalizeIsoDate(dateValue) {
+ if (!dateValue) {
+ return "";
+ }
+
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
+ return `${dateValue}T00:00:00Z`;
+ }
+
+ return dateValue;
+}
+
+async function fetchUnreleasedGithubSignals(context, sinceDate) {
+ if (!context.repoSlug.includes("/")) {
+ return { mergedPulls: [], closedIssues: [] };
+ }
+
+ const sinceIso = normalizeIsoDate(sinceDate);
+ if (!sinceIso) {
+ return { mergedPulls: [], closedIssues: [] };
+ }
+
+ const [owner, repo] = context.repoSlug.split("/");
+ const pullsUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
+ const issuesUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed&since=${encodeURIComponent(sinceIso)}&sort=updated&direction=desc&per_page=100`;
+
+ try {
+ const [pulls, issues] = await Promise.all([
+ fetchGithubJson(pullsUrl),
+ fetchGithubJson(issuesUrl),
+ ]);
+
+ const mergedPulls = pulls
+ .filter((pull) => isRecord(pull) && typeof pull.merged_at === "string")
+ .filter((pull) => Date.parse(pull.merged_at) >= Date.parse(sinceIso))
+ .map((pull) => ({
+ number: Number(pull.number) || 0,
+ title: pickString(pull.title, "Merged pull request"),
+ }))
+ .filter((pull) => pull.number > 0);
+
+ const closedIssues = issues
+ .filter((issue) => isRecord(issue) && !issue.pull_request)
+ .filter((issue) => typeof issue.closed_at === "string")
+ .filter((issue) => Date.parse(issue.closed_at) >= Date.parse(sinceIso))
+ .map((issue) => ({
+ number: Number(issue.number) || 0,
+ title: pickString(issue.title, "Closed issue"),
+ }))
+ .filter((issue) => issue.number > 0);
+
+ return { mergedPulls, closedIssues };
+ } catch {
+ return { mergedPulls: [], closedIssues: [] };
+ }
+}
+
+async function buildReleaseFromRepository(context, mode, selectedTag) {
+ const tags = listReleaseTags(context.repoRoot);
+ const latestTag = tags[0] ?? "";
+
+ if (mode === "tag" && selectedTag && tags.includes(selectedTag)) {
+ const index = tags.indexOf(selectedTag);
+ const previousTag = index >= 0 && index < tags.length - 1 ? tags[index + 1] : "";
+ const rangeExpr = previousTag ? `${previousTag}..${selectedTag}` : selectedTag;
+ const releaseDate = readTagDate(context.repoRoot, selectedTag) || sampleRelease.releaseDate;
+ const commits = readCommitSummaries(context.repoRoot, rangeExpr);
+ const releaseUrl =
+ context.repoUrl !== "https://github.com/"
+ ? `${context.repoUrl}/releases/tag/${encodeURIComponent(selectedTag)}`
+ : context.repoUrl;
+
+ return toReleaseStateFromCommits(context, commits, {
+ version: selectedTag,
+ releaseDate,
+ rangeLabel: rangeExpr,
+ sinceLabel: previousTag || selectedTag,
+ callToActionLabel: `View ${selectedTag} release`,
+ callToActionUrl: releaseUrl,
+ });
+ }
+
+ const rangeExpr = latestTag ? `${latestTag}..HEAD` : "HEAD";
+ const commits = readCommitSummaries(context.repoRoot, rangeExpr);
+ const latestTagDate = latestTag ? readTagDate(context.repoRoot, latestTag) : "";
+ const unreleasedSignals = latestTagDate
+ ? await fetchUnreleasedGithubSignals(context, latestTagDate)
+ : { mergedPulls: [], closedIssues: [] };
+ const compareUrl =
+ context.repoUrl !== "https://github.com/" && latestTag
+ ? `${context.repoUrl}/compare/${encodeURIComponent(latestTag)}...HEAD`
+ : context.repoUrl;
+
+ return toReleaseStateFromCommits(context, commits, {
+ version: "vNext",
+ releaseDate: sampleRelease.releaseDate,
+ rangeLabel: rangeExpr,
+ sinceLabel: latestTag || "the beginning of the branch",
+ mergedPulls: unreleasedSignals.mergedPulls,
+ closedIssues: unreleasedSignals.closedIssues,
+ callToActionLabel: latestTag ? "Review unreleased commits" : "View repository",
+ callToActionUrl: compareUrl,
+ });
+}
+
+export const releaseNotesShowcaseCanvas = createCanvas({
+ id: CANVAS_ID,
+ displayName: CANVAS_TITLE,
+ description:
+ "Presents release notes as a high-impact launch summary with contributor callouts and email-ready export output.",
+ inputSchema: releaseNotesInputSchema,
+ actions: [
+ {
+ name: "export_email",
+ description:
+ "Returns email-ready subject, HTML, and text for the release notes currently shown in the canvas.",
+ inputSchema: exportInputSchema,
+ handler: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ throw new CanvasError(
+ "canvas_state_missing",
+ "Open the release notes canvas before exporting email content.",
+ );
+ }
+
+ return buildExportPayload(entry.getState(), ctx.input);
+ },
+ },
+ {
+ name: "get_release_snapshot",
+ description:
+ "Returns a concise snapshot of the release story shown in the canvas.",
+ handler: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ throw new CanvasError(
+ "canvas_state_missing",
+ "Open the release notes canvas before requesting a snapshot.",
+ );
+ }
+
+ const state = entry.getState();
+ return {
+ title: `${state.releaseName} ${state.version}`,
+ summary: state.summary,
+ sections: state.sections.map((section) => ({
+ title: section.title,
+ kind: section.kind,
+ })),
+ contributors: state.contributors.map((contributor) => contributor.name),
+ };
+ },
+ },
+ ],
+ open: async (ctx) => {
+ repositoryContext = resolveRepositoryContext(ctx.session?.workingDirectory, ctx.sessionId);
+ sampleRelease = Object.freeze(buildDefaultRelease(repositoryContext));
+ const state = buildState(ctx.input);
+
+ let entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ entry = await startServer(state);
+ servers.set(ctx.instanceId, entry);
+ } else {
+ entry.setState(state);
+ }
+
+ return {
+ title: `${state.releaseName} ${state.version}`,
+ status: `${state.contributors.length} contributors highlighted`,
+ url: entry.url,
+ };
+ },
+ onClose: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ return;
+ }
+
+ servers.delete(ctx.instanceId);
+ await new Promise((resolve) => entry.server.close(resolve));
+ },
+});
+
+function buildState(input) {
+ const candidate = isRecord(input) ? input : {};
+ const releaseName = pickString(candidate.releaseName, sampleRelease.releaseName);
+ const version = pickString(candidate.version, sampleRelease.version);
+ const summary = pickString(candidate.summary, sampleRelease.summary);
+ const sections = normalizeSections(candidate.sections);
+ const contributors = normalizeContributors(candidate.contributors);
+ const heroStats = normalizeHeroStats(candidate.heroStats, sections, contributors);
+ const emailSubject = pickString(
+ candidate.emailSubject,
+ `${releaseName} ${version} - release highlights`,
+ );
+
+ return {
+ releaseName,
+ version,
+ releaseDate: pickString(candidate.releaseDate, sampleRelease.releaseDate),
+ tagline: pickString(candidate.tagline, sampleRelease.tagline),
+ summary,
+ emailSubject,
+ emailPreheader: pickString(candidate.emailPreheader, summary),
+ heroStats,
+ sections,
+ contributors,
+ communityThanks: normalizeCommunityThanks(candidate.communityThanks),
+ otherChanges: normalizeOtherChanges(candidate.otherChanges),
+ callToAction: normalizeCallToAction(candidate.callToAction),
+ };
+}
+
+function normalizeCommunityThanks(value) {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const handles = value
+ .filter((handle) => typeof handle === "string")
+ .map((handle) => handle.trim().replace(/^@/, ""))
+ .filter((handle) => handle.length > 0);
+
+ return handles;
+}
+
+function normalizeOtherChanges(value) {
+ if (!Array.isArray(value) || value.length === 0) {
+ return [];
+ }
+
+ const changes = value
+ .filter(isRecord)
+ .map((change) => ({
+ label: pickString(change.label, ""),
+ text: pickString(change.text, ""),
+ }))
+ .filter((change) => change.text);
+
+ return changes;
+}
+
+function normalizeSections(value) {
+ if (!Array.isArray(value) || value.length === 0) {
+ return [];
+ }
+
+ return value
+ .filter(isRecord)
+ .map((section) => {
+ const kind = isSectionKind(section.kind) ? section.kind : "feature";
+ const bullets = toStringArray(section.bullets);
+ const title = pickString(section.title, "");
+ const summary = pickString(section.summary, "");
+ if (!title || !summary) {
+ return null;
+ }
+
+ return {
+ title,
+ kind,
+ summary,
+ metric: pickString(section.metric, ""),
+ bullets,
+ };
+ })
+ .filter(Boolean);
+}
+
+function normalizeContributors(value) {
+ if (!Array.isArray(value) || value.length === 0) {
+ return [];
+ }
+
+ return value
+ .filter(isRecord)
+ .map((contributor) => {
+ const name = pickString(contributor.name, "");
+ if (!name) {
+ return null;
+ }
+
+ return {
+ name,
+ githubHandle: pickString(contributor.githubHandle, ""),
+ avatarUrl: pickString(contributor.avatarUrl, ""),
+ profileUrl: pickString(contributor.profileUrl, ""),
+ area: pickString(contributor.area, ""),
+ summary: pickString(contributor.summary, ""),
+ };
+ })
+ .filter(Boolean);
+}
+
+function normalizeHeroStats(value, sections, contributors) {
+ if (Array.isArray(value) && value.length > 0) {
+ const stats = value
+ .filter(isRecord)
+ .map((stat) => ({
+ label: pickString(stat.label, ""),
+ value: pickString(stat.value, ""),
+ }))
+ .filter((stat) => stat.label && stat.value);
+
+ if (stats.length > 0) {
+ return stats;
+ }
+ }
+
+ return [
+ {
+ label: "Top features",
+ value: padCount(countByKind(sections, "feature")),
+ },
+ {
+ label: "Core improvements",
+ value: padCount(countByKind(sections, "improvement")),
+ },
+ {
+ label: "Contributors",
+ value: padCount(contributors.length),
+ },
+ {
+ label: "Areas touched",
+ value: padCount(sections.length),
+ },
+ ];
+}
+
+function normalizeCallToAction(value) {
+ if (isRecord(value)) {
+ return {
+ label: pickString(value.label, sampleRelease.callToAction.label),
+ url: pickString(value.url, sampleRelease.callToAction.url),
+ };
+ }
+
+ return { ...sampleRelease.callToAction };
+}
+
+function buildExportPayload(state, input) {
+ const format = isRecord(input) ? pickString(input.format, "both") : "both";
+ const html = buildEmailHtml(state);
+ const text = buildEmailText(state);
+ const payload = {
+ subject: state.emailSubject,
+ preheader: state.emailPreheader,
+ fileNameBase: slugify(`${state.releaseName}-${state.version}-release-notes-email`),
+ };
+
+ if (format === "html") {
+ return { ...payload, html };
+ }
+
+ if (format === "text") {
+ return { ...payload, text };
+ }
+
+ return { ...payload, html, text };
+}
+
+function buildEmailHtml(state) {
+ const sectionRows = state.sections
+ .map((section) => {
+ const bullets = section.bullets
+ .map(
+ (bullet) =>
+ `${escapeHtml(bullet)}`,
+ )
+ .join("");
+
+ return `
+
+ |
+
+ ${escapeHtml(kindLabel(section.kind))}
+
+
+ ${escapeHtml(section.title)}
+
+
+ ${escapeHtml(section.summary)}
+
+
+ ${escapeHtml(section.metric)}
+
+
+ |
+
+ `;
+ })
+ .join("");
+
+ const contributorRows = state.contributors
+ .map(
+ (contributor) => `
+
+ |
+
+ |
+
+ `,
+ )
+ .join("");
+
+ const otherChangesHtml = (state.otherChanges ?? [])
+ .map(
+ (change) => `
+ ${change.label ? `${escapeHtml(change.label)}: ` : ""}${escapeHtml(change.text)}
+ `,
+ )
+ .join("");
+
+ const communityHtml = (state.communityThanks ?? [])
+ .map(
+ (handle) =>
+ `@${escapeHtml(handle)}`,
+ )
+ .join(" · ");
+
+ return `
+
+
+
+
+ ${escapeHtml(state.emailSubject)}
+
+
+
+ ${escapeHtml(state.emailPreheader)}
+
+
+
+
+
+
+ |
+
+ ${escapeHtml(state.releaseDate)}
+
+
+ ${escapeHtml(`${state.releaseName} ${state.version}`)}
+
+
+ ${escapeHtml(state.tagline)}
+
+
+ ${escapeHtml(state.summary)}
+
+ |
+
+
+
+
+
+ ${state.heroStats
+ .map(
+ (stat) => `
+
+
+
+ |
+ ${escapeHtml(stat.value)}
+ ${escapeHtml(stat.label)}
+ |
+
+
+ |
+ `,
+ )
+ .join("")}
+
+
+ |
+
+
+ |
+ ${sectionRows}
+ |
+
+
+
+
+ Also in this release
+
+
+ |
+
+
+
+
+ Contributors in the spotlight
+
+ ${contributorRows}
+
+ Community thanks: ${communityHtml}
+
+ |
+
+
+ |
+
+ ${escapeHtml(state.callToAction.label)}
+
+ |
+
+
+ |
+
+
+
+`;
+}
+
+function buildEmailText(state) {
+ const sectionText = state.sections
+ .map((section) => {
+ const bullets = section.bullets.map((bullet) => `- ${bullet}`).join("\n");
+ return `${kindLabel(section.kind).toUpperCase()}: ${section.title}\n${section.summary}\n${bullets}`;
+ })
+ .join("\n\n");
+
+ const contributorText = state.contributors
+ .map(
+ (contributor) =>
+ `- ${contributor.name} (${contributor.area}): ${contributor.summary}`,
+ )
+ .join("\n");
+
+ const otherChangesText = (state.otherChanges ?? [])
+ .map((change) => `- ${change.label ? `${change.label}: ` : ""}${change.text}`)
+ .join("\n");
+
+ const communityText = (state.communityThanks ?? [])
+ .map((handle) => `@${handle}`)
+ .join(", ");
+
+ return `${state.releaseName} ${state.version}
+${state.releaseDate}
+
+${state.tagline}
+
+${state.summary}
+
+Highlights
+${state.heroStats.map((stat) => `- ${stat.label}: ${stat.value}`).join("\n")}
+
+${sectionText}
+
+Also in this release
+${otherChangesText}
+
+Contributors in the spotlight
+${contributorText}
+
+Community thanks: ${communityText}
+
+${state.callToAction.label}: ${state.callToAction.url}`;
+}
+
+async function startServer(initialState) {
+ let state = initialState;
+
+ const server = createServer(async (req, res) => {
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
+
+ if (req.method === "GET" && requestUrl.pathname === "/") {
+ respondHtml(res, renderHtml(state));
+ return;
+ }
+
+ if (req.method === "POST" && requestUrl.pathname === "/actions/export-email") {
+ const body = await readJsonBody(req);
+ respondJson(res, buildExportPayload(state, body));
+ return;
+ }
+
+ if (req.method === "GET" && requestUrl.pathname === "/actions/release-options") {
+ const tags = listReleaseTags(repositoryContext.repoRoot);
+ respondJson(res, {
+ repository: repositoryContext.repoSlug,
+ tags: tags.map((tag) => ({ value: tag, label: tag })),
+ latestTag: tags[0] ?? "",
+ });
+ return;
+ }
+
+ if (req.method === "POST" && requestUrl.pathname === "/actions/load-release") {
+ const body = await readJsonBody(req);
+ const mode = pickString(body?.mode, "unreleased");
+ const selectedTag = pickString(body?.tag, "");
+ if (mode !== "unreleased" && mode !== "tag") {
+ respondJson(res, { error: "Invalid release mode." }, 400);
+ return;
+ }
+
+ state = await buildReleaseFromRepository(repositoryContext, mode, selectedTag);
+ respondJson(res, {
+ title: `${state.releaseName} ${state.version}`,
+ summary: state.summary,
+ });
+ return;
+ }
+
+ respondJson(res, { error: "Not found" }, 404);
+ });
+
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+
+ return {
+ server,
+ url: `http://127.0.0.1:${port}/`,
+ getState() {
+ return state;
+ },
+ setState(nextState) {
+ state = nextState;
+ },
+ };
+}
+
+function renderHtml(state) {
+ const metricPalette = [
+ { bg: "#f5e0dc", border: "rgba(220, 138, 120, 0.22)", value: "#dd7878" },
+ { bg: "#dce7fb", border: "rgba(30, 102, 245, 0.22)", value: "#1e66f5" },
+ { bg: "#e7f3e0", border: "rgba(64, 160, 43, 0.22)", value: "#40a02b" },
+ { bg: "#efe3fb", border: "rgba(136, 57, 239, 0.22)", value: "#8839ef" },
+ ];
+
+ const statCards = state.heroStats
+ .map((stat, index) => {
+ const tone = metricPalette[index % metricPalette.length];
+ return `
+
+
${escapeHtml(stat.value)}
+
${escapeHtml(stat.label)}
+
+ `;
+ })
+ .join("");
+
+ const featureCards = state.sections
+ .map((section) => {
+ const bullets = section.bullets
+ .slice(0, 2)
+ .map((bullet) => `${escapeHtml(bullet)}`)
+ .join("");
+
+ return `
+
+
+
${escapeHtml(kindLabel(section.kind))}
+
${escapeHtml(section.metric)}
+
+ ${escapeHtml(section.title)}
+ ${escapeHtml(section.summary)}
+
+
+ `;
+ })
+ .join("");
+
+ const contributorCards = state.contributors
+ .map((contributor) => {
+ const avatar = contributor.avatarUrl
+ ? `
`
+ : `${escapeHtml(getInitials(contributor.name))}
`;
+ const profileHref = contributor.profileUrl || "#";
+ const handle = contributor.githubHandle ? `@${contributor.githubHandle}` : "";
+
+ return `
+
+ ${avatar}
+
+
+
${escapeHtml(contributor.area)}
+
${escapeHtml(contributor.summary)}
+
+
+ `;
+ })
+ .join("");
+
+ const communityChips = (state.communityThanks ?? [])
+ .map((handle) => {
+ const profile = `https://github.com/${encodeURIComponent(handle)}`;
+ const avatar = `https://github.com/${encodeURIComponent(handle)}.png?size=64`;
+ return `
+
+
+ @${escapeHtml(handle)}
+
+ `;
+ })
+ .join("");
+
+ const otherChangeRows = (state.otherChanges ?? [])
+ .map((change) => {
+ const label = change.label
+ ? `${escapeHtml(change.label)}`
+ : "";
+ return `${label}${escapeHtml(change.text)}`;
+ })
+ .join("");
+
+ const featureHeadline = state.sections[0]?.title ?? "Release highlights";
+
+ return `
+
+
+
+
+ ${escapeHtml(`${state.releaseName} ${state.version}`)}
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(state.releaseName)} repository
+
+
${escapeHtml(state.releaseDate)} · ✨ Fresh from the repo
+
${escapeHtml(state.releaseName)} ${escapeHtml(state.version)}
+
${escapeHtml(state.tagline)}
+
${escapeHtml(state.summary)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${featureCards}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+function readJsonBody(req) {
+ return new Promise((resolve, reject) => {
+ let body = "";
+
+ req.setEncoding("utf8");
+ req.on("data", (chunk) => {
+ body += chunk;
+ });
+ req.on("end", () => {
+ if (!body.trim()) {
+ resolve({});
+ return;
+ }
+
+ try {
+ resolve(JSON.parse(body));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ req.on("error", reject);
+ });
+}
+
+function respondHtml(res, html) {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(html);
+}
+
+function respondJson(res, payload, statusCode = 200) {
+ res.statusCode = statusCode;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(payload));
+}
+
+function pickString(value, fallback) {
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
+}
+
+function toStringArray(value) {
+ return Array.isArray(value)
+ ? value
+ .filter((item) => typeof item === "string" && item.trim())
+ .map((item) => item.trim())
+ : [];
+}
+
+function isRecord(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function isSectionKind(value) {
+ return value === "feature" || value === "improvement" || value === "quality";
+}
+
+function countByKind(sections, kind) {
+ return sections.filter((section) => section.kind === kind).length;
+}
+
+function padCount(value) {
+ return String(value).padStart(2, "0");
+}
+
+function kindLabel(kind) {
+ if (kind === "feature") {
+ return "🚀 Feature work";
+ }
+
+ if (kind === "improvement") {
+ return "✨ Improvement";
+ }
+
+ return "🛡️ Quality";
+}
+
+function emailAccent(kind) {
+ if (kind === "feature") {
+ return {
+ chip: "#dbeafe",
+ ink: "#1d4ed8",
+ };
+ }
+
+ if (kind === "improvement") {
+ return {
+ chip: "#f3e8ff",
+ ink: "#7e22ce",
+ };
+ }
+
+ return {
+ chip: "#ffedd5",
+ ink: "#c2410c",
+ };
+}
+
+function getInitials(name) {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((segment) => segment[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function slugify(value) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
diff --git a/extensions/where-was-i/assets/preview.png b/extensions/where-was-i/assets/preview.png
new file mode 100644
index 00000000..16336a74
Binary files /dev/null and b/extensions/where-was-i/assets/preview.png differ
diff --git a/website/src/pages/extensions.astro b/website/src/pages/extensions.astro
index 98e98a26..78ae6f27 100644
--- a/website/src/pages/extensions.astro
+++ b/website/src/pages/extensions.astro
@@ -36,6 +36,17 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
+
+
+
+
+
![]()
+
+
+
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index 5420b2ba..e10427da 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -40,6 +40,11 @@ const base = import.meta.env.BASE_URL;
Community-contributed agents, instructions, and skills to enhance your
GitHub Copilot experience
+
+
+ View the Awesome Copilot repository on GitHub
+
+
diff --git a/website/src/scripts/pages/extensions-render.ts b/website/src/scripts/pages/extensions-render.ts
index b872eef0..65e04c96 100644
--- a/website/src/scripts/pages/extensions-render.ts
+++ b/website/src/scripts/pages/extensions-render.ts
@@ -3,9 +3,15 @@ import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
export interface RenderableExtension {
id: string;
name: string;
- path: string;
- ref: string;
+ path?: string | null;
+ ref?: string | null;
+ description?: string;
lastUpdated?: string | null;
+ imageUrl?: string | null;
+ assetPath?: string | null;
+ installUrl?: string | null;
+ sourceUrl?: string | null;
+ external?: boolean;
}
export type ExtensionSortOption = "title" | "lastUpdated";
@@ -36,37 +42,62 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
}
return items
- .map(
- (item) => `
+ .map((item) => {
+ const installUrl =
+ item.installUrl ||
+ (item.path && item.ref
+ ? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
+ /\\/g,
+ "/"
+ )}`
+ : "");
+ const sourceUrl =
+ item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
+
+ return `
-
-
${escapeHtml(item.name)}
-
Canvas extension
-
- ${getLastUpdatedHtml(item.lastUpdated)}
-
-
-
+ ${
+ item.imageUrl
+ ? ``
+ : `Canvas
`
+ }
+
+
${escapeHtml(item.name)}
+
${escapeHtml(
+ item.description || "Canvas extension"
+ )}
+
+ ${
+ item.external
+ ? 'External'
+ : ""
+ }
+ ${getLastUpdatedHtml(item.lastUpdated)}
+
+
+
- `
- )
+ `;
+ })
.join("");
}
diff --git a/website/src/scripts/pages/extensions.ts b/website/src/scripts/pages/extensions.ts
index c839ea02..f9dae4c5 100644
--- a/website/src/scripts/pages/extensions.ts
+++ b/website/src/scripts/pages/extensions.ts
@@ -27,6 +27,30 @@ let allItems: Extension[] = [];
let currentSort: ExtensionSortOption = "title";
let actionHandlersReady = false;
+function openPreviewModal(url: string, alt: string): void {
+ const modal = document.getElementById("extension-preview-modal");
+ const image = document.getElementById("extension-preview-image") as HTMLImageElement | null;
+ const title = document.getElementById("extension-preview-title");
+
+ if (!modal || !image || !title) return;
+
+ image.src = url;
+ image.alt = alt;
+ title.textContent = alt.replace(/ preview$/i, "");
+ modal.classList.remove("hidden");
+ modal.setAttribute("aria-hidden", "false");
+ document.body.style.overflow = "hidden";
+}
+
+function closePreviewModal(): void {
+ const modal = document.getElementById("extension-preview-modal");
+ if (!modal) return;
+
+ modal.classList.add("hidden");
+ modal.setAttribute("aria-hidden", "true");
+ document.body.style.overflow = "";
+}
+
function applySortAndRender(): void {
const countEl = document.getElementById("results-count");
const results = sortExtensions(allItems, currentSort);
@@ -49,6 +73,20 @@ function setupActionHandlers(list: HTMLElement | null): void {
list.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
+ const thumbnailButton = target.closest(
+ ".resource-thumbnail-btn"
+ ) as HTMLButtonElement | null;
+
+ if (thumbnailButton) {
+ event.preventDefault();
+ event.stopPropagation();
+ openPreviewModal(
+ thumbnailButton.dataset.previewUrl || "",
+ thumbnailButton.dataset.previewAlt || "Extension preview"
+ );
+ return;
+ }
+
const installButton = target.closest(
".copy-install-url-btn"
) as HTMLButtonElement | null;
@@ -57,6 +95,10 @@ function setupActionHandlers(list: HTMLElement | null): void {
event.stopPropagation();
const installUrl = installButton.dataset.installUrl || "";
+ if (!installUrl) {
+ showToast("No install URL available for this extension", "error");
+ return;
+ }
const success = await copyToClipboard(installUrl);
showToast(
success ? "Install URL copied!" : "Failed to copy install URL",
@@ -64,6 +106,27 @@ function setupActionHandlers(list: HTMLElement | null): void {
);
});
+ const modal = document.getElementById("extension-preview-modal");
+ const closeButton = document.getElementById("extension-preview-close");
+
+ if (modal) {
+ modal.addEventListener("click", (event) => {
+ if (event.target === modal) {
+ closePreviewModal();
+ }
+ });
+ }
+
+ if (closeButton) {
+ closeButton.addEventListener("click", closePreviewModal);
+ }
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ closePreviewModal();
+ }
+ });
+
actionHandlersReady = true;
}
diff --git a/website/src/styles/global.css b/website/src/styles/global.css
index 81f21e07..04bd3718 100644
--- a/website/src/styles/global.css
+++ b/website/src/styles/global.css
@@ -1863,7 +1863,9 @@ body:has(#main-content) {
.resource-preview {
flex: 1;
min-width: 0;
- display: block;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
width: 100%;
padding: 0;
margin: 0;
@@ -1874,7 +1876,56 @@ body:has(#main-content) {
text-align: left;
cursor: pointer;
}
-
+
+.resource-thumbnail-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.resource-thumbnail-btn:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 4px;
+ border-radius: var(--border-radius);
+}
+
+.resource-thumbnail {
+ width: clamp(120px, 24vw, 160px);
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-glass-border);
+ background: var(--color-bg-tertiary);
+ flex-shrink: 0;
+ box-shadow: var(--shadow);
+ transition: transform var(--transition), box-shadow var(--transition);
+}
+
+.resource-thumbnail-btn:hover .resource-thumbnail,
+.resource-thumbnail-btn:focus-visible .resource-thumbnail {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-lg);
+}
+
+.resource-thumbnail-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-muted);
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ background:
+ linear-gradient(135deg, rgba(133, 52, 243, 0.18), rgba(254, 76, 37, 0.08)),
+ var(--color-bg-tertiary);
+}
+
.resource-preview:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
@@ -1930,6 +1981,72 @@ body:has(#main-content) {
margin: 0px;
}
+.extension-preview-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 100000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(5, 7, 15, 0.72);
+ backdrop-filter: blur(8px);
+}
+
+.extension-preview-modal.hidden {
+ display: none;
+}
+
+.extension-preview-dialog {
+ width: min(100%, 980px);
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-glass-border);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+
+.extension-preview-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 18px 0;
+}
+
+.extension-preview-title {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--color-text-emphasis);
+}
+
+.extension-preview-close {
+ border: 1px solid var(--color-glass-border);
+ background: var(--color-bg-tertiary);
+}
+
+.extension-preview-body {
+ padding: 0 18px 18px;
+ display: flex;
+ justify-content: center;
+ overflow: auto;
+}
+
+.extension-preview-image {
+ display: block;
+ width: 100%;
+ max-width: 920px;
+ max-height: 72vh;
+ object-fit: contain;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-glass-border);
+ background: var(--color-bg-tertiary);
+}
+
/* Last Updated */
.last-updated {
font-size: 12px;
@@ -2081,6 +2198,20 @@ body:has(#main-content) {
align-items: stretch;
}
+ .resource-preview {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .resource-thumbnail-btn {
+ width: 100%;
+ }
+
+ .resource-thumbnail {
+ width: min(100%, 320px);
+ max-width: 100%;
+ }
+
.resource-actions {
justify-content: flex-end;
}