From 5df994bb76f5ebfdfaf4e095b4a89c21faec8490 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 2 Jun 2026 11:02:04 -0700 Subject: [PATCH] Add Canvas Extensions website page Generate extensions data, add the extensions listing route/navigation, and include install URL copy actions pinned to the build commit SHA. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/constants.mjs | 2 + eng/generate-website-data.mjs | 118 +- extensions/accessibility-kanban/extension.mjs | 446 ++++++ extensions/accessibility-kanban/package.json | 9 + .../accessibility-kanban/public/index.html | 627 +++++++++ extensions/color-orb/extension.mjs | 289 ++++ extensions/color-orb/package-lock.json | 218 +++ extensions/color-orb/package.json | 9 + extensions/diagram-viewer/extension.mjs | 390 ++++++ extensions/diagram-viewer/package-lock.json | 218 +++ extensions/diagram-viewer/package.json | 9 + extensions/diagram-viewer/public/index.html | 721 ++++++++++ extensions/feedback-themes/data/signals.json | 244 ++++ extensions/feedback-themes/extension.mjs | 196 +++ extensions/feedback-themes/package-lock.json | 218 +++ extensions/feedback-themes/package.json | 9 + extensions/feedback-themes/public/index.html | 419 ++++++ extensions/gesture-review/extension.mjs | 1237 +++++++++++++++++ extensions/gesture-review/package-lock.json | 218 +++ extensions/gesture-review/package.json | 9 + extensions/where-was-i/extension.mjs | 747 ++++++++++ website/astro.config.mjs | 1 + website/src/pages/extensions.astro | 57 + website/src/pages/index.astro | 25 +- .../src/scripts/pages/extensions-render.ts | 72 + website/src/scripts/pages/extensions.ts | 110 ++ website/src/scripts/pages/index.ts | 2 + 27 files changed, 6617 insertions(+), 3 deletions(-) create mode 100644 extensions/accessibility-kanban/extension.mjs create mode 100644 extensions/accessibility-kanban/package.json create mode 100644 extensions/accessibility-kanban/public/index.html create mode 100644 extensions/color-orb/extension.mjs create mode 100644 extensions/color-orb/package-lock.json create mode 100644 extensions/color-orb/package.json create mode 100644 extensions/diagram-viewer/extension.mjs create mode 100644 extensions/diagram-viewer/package-lock.json create mode 100644 extensions/diagram-viewer/package.json create mode 100644 extensions/diagram-viewer/public/index.html create mode 100644 extensions/feedback-themes/data/signals.json create mode 100644 extensions/feedback-themes/extension.mjs create mode 100644 extensions/feedback-themes/package-lock.json create mode 100644 extensions/feedback-themes/package.json create mode 100644 extensions/feedback-themes/public/index.html create mode 100644 extensions/gesture-review/extension.mjs create mode 100644 extensions/gesture-review/package-lock.json create mode 100644 extensions/gesture-review/package.json create mode 100644 extensions/where-was-i/extension.mjs create mode 100644 website/src/pages/extensions.astro create mode 100644 website/src/scripts/pages/extensions-render.ts create mode 100644 website/src/scripts/pages/extensions.ts diff --git a/eng/constants.mjs b/eng/constants.mjs index 5f19c9969..3716a858d 100644 --- a/eng/constants.mjs +++ b/eng/constants.mjs @@ -194,6 +194,7 @@ const INSTRUCTIONS_DIR = path.join(ROOT_FOLDER, "instructions"); const AGENTS_DIR = path.join(ROOT_FOLDER, "agents"); const SKILLS_DIR = path.join(ROOT_FOLDER, "skills"); const HOOKS_DIR = path.join(ROOT_FOLDER, "hooks"); +const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const WORKFLOWS_DIR = path.join(ROOT_FOLDER, "workflows"); const COOKBOOK_DIR = path.join(ROOT_FOLDER, "cookbook"); @@ -212,6 +213,7 @@ export { AKA_INSTALL_URLS, COOKBOOK_DIR, DOCS_DIR, + EXTENSIONS_DIR, HOOKS_DIR, INSTRUCTIONS_DIR, MAX_PLUGIN_ITEMS, diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 4ef284282..59723d1b2 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -9,9 +9,11 @@ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import { execSync } from "child_process"; import { AGENTS_DIR, COOKBOOK_DIR, + EXTENSIONS_DIR, HOOKS_DIR, INSTRUCTIONS_DIR, PLUGINS_DIR, @@ -64,6 +66,68 @@ function extractTitle(filePath, frontmatter) { .join(" "); } +/** + * Convert kebab/snake names into readable titles. + */ +function formatDisplayName(value) { + const acronymMap = new Map([ + ["ai", "AI"], + ["api", "API"], + ["cli", "CLI"], + ["css", "CSS"], + ["html", "HTML"], + ["json", "JSON"], + ["llm", "LLM"], + ["mcp", "MCP"], + ["ui", "UI"], + ["ux", "UX"], + ["vscode", "VS Code"], + ]); + + return value + .split(/[-_]+/) + .filter(Boolean) + .map((part) => { + const lower = part.toLowerCase(); + if (acronymMap.has(lower)) { + return acronymMap.get(lower); + } + return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); + }) + .join(" "); +} + +/** + * Find the latest git-modified date for any file under a directory. + */ +function getDirectoryLastUpdated(gitDates, relativeDirPath) { + const prefix = `${relativeDirPath}/`; + let latestDate = null; + let latestTime = 0; + + for (const [filePath, date] of gitDates.entries()) { + if (!filePath.startsWith(prefix)) continue; + const timestamp = Date.parse(date); + if (!Number.isNaN(timestamp) && timestamp > latestTime) { + latestTime = timestamp; + latestDate = date; + } + } + + return latestDate; +} + +/** + * Get the current commit SHA for the checked-out repository. + */ +function getCurrentCommitSha() { + return execSync("git --no-pager rev-parse HEAD", { + cwd: ROOT_FOLDER, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + /** * Generate agents metadata */ @@ -603,6 +667,38 @@ function generatePluginsData(gitDates) { }; } +/** + * Generate canvas extensions metadata + */ +function generateExtensionsData(gitDates, commitSha) { + const extensions = []; + + if (!fs.existsSync(EXTENSIONS_DIR)) { + return { items: [] }; + } + + const extensionDirs = fs + .readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()); + + for (const dir of extensionDirs) { + const relPath = `extensions/${dir.name}`; + extensions.push({ + id: dir.name, + name: formatDisplayName(dir.name), + path: relPath, + ref: commitSha, + lastUpdated: getDirectoryLastUpdated(gitDates, relPath), + }); + } + + const sortedExtensions = extensions.sort((a, b) => + a.name.localeCompare(b.name) + ); + + return { items: sortedExtensions }; +} + /** * Generate tools metadata from website/data/tools.yml */ @@ -893,12 +989,22 @@ async function main() { // Load git dates for all resource files (single efficient git command) console.log("Loading git history for last updated dates..."); const gitDates = getGitFileDates( - ["agents/", "instructions/", "hooks/", "workflows/", "skills/", "plugins/"], + [ + "agents/", + "instructions/", + "hooks/", + "workflows/", + "skills/", + "extensions/", + "plugins/", + ], ROOT_FOLDER ); console.log(`✓ Loaded dates for ${gitDates.size} files\n`); // Generate all data + const commitSha = getCurrentCommitSha(); + const agentsData = generateAgentsData(gitDates); const agents = agentsData.items; console.log( @@ -933,6 +1039,10 @@ async function main() { `✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)` ); + const extensionsData = generateExtensionsData(gitDates, commitSha); + const extensions = extensionsData.items; + console.log(`✓ Generated ${extensions.length} extensions`); + const toolsData = generateToolsData(); const tools = toolsData.items; console.log( @@ -991,6 +1101,11 @@ async function main() { JSON.stringify(pluginsData, null, 2) ); + fs.writeFileSync( + path.join(WEBSITE_DATA_DIR, "extensions.json"), + JSON.stringify(extensionsData, null, 2) + ); + fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "tools.json"), JSON.stringify(toolsData, null, 2) @@ -1016,6 +1131,7 @@ async function main() { hooks: hooks.length, workflows: workflows.length, plugins: plugins.length, + extensions: extensions.length, tools: tools.length, contributors: contributorCount, samples: samplesData.totalRecipes, diff --git a/extensions/accessibility-kanban/extension.mjs b/extensions/accessibility-kanban/extension.mjs new file mode 100644 index 000000000..999805ce6 --- /dev/null +++ b/extensions/accessibility-kanban/extension.mjs @@ -0,0 +1,446 @@ +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_NAME = "accessibility-kanban"; +const STATE_FILE = "signalbox-accessibility-kanban-state.json"; +const COLUMNS = ["backlog", "plan", "ready", "implement", "done"]; +const VALID_COLUMNS = new Set(COLUMNS); + +const defaultIssues = [ + { + number: 39, + title: "Add keyboard trap prevention for modal-like interactions", + url: "https://github.com/sethjuarez/SignalBox/issues/39", + labels: ["signalbox-mvp", "frontend", "accessibility"], + column: "backlog", + priority: "high", + }, + { + number: 38, + title: "Ensure color contrast meets WCAG AA for all text", + url: "https://github.com/sethjuarez/SignalBox/issues/38", + labels: ["signalbox-mvp", "product-polish", "accessibility"], + column: "backlog", + priority: "high", + }, + { + number: 37, + title: "Add aria-live region for form submission feedback", + url: "https://github.com/sethjuarez/SignalBox/issues/37", + labels: ["signalbox-mvp", "frontend", "accessibility"], + column: "backlog", + priority: "high", + }, + { + number: 36, + title: "Add focus-visible outline to all interactive elements", + url: "https://github.com/sethjuarez/SignalBox/issues/36", + labels: ["signalbox-mvp", "frontend", "accessibility"], + column: "backlog", + priority: "high", + }, + { + number: 35, + title: "Add aria-hidden to decorative SVG icons in AuthPage", + url: "https://github.com/sethjuarez/SignalBox/issues/35", + labels: ["signalbox-mvp", "frontend", "accessibility"], + column: "backlog", + priority: "medium", + }, + { + number: 20, + title: "Audit and fix form field label association and aria-describedby", + url: "https://github.com/sethjuarez/SignalBox/issues/20", + labels: ["signalbox-mvp", "frontend", "product-polish", "accessibility"], + column: "backlog", + priority: "medium", + }, + { + number: 19, + title: "Ensure consistent keyboard focus styles across the intake form", + url: "https://github.com/sethjuarez/SignalBox/issues/19", + labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"], + column: "backlog", + priority: "medium", + }, + { + number: 17, + title: "Add accessible client-side validation errors to the intake form", + url: "https://github.com/sethjuarez/SignalBox/issues/17", + labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"], + column: "backlog", + priority: "medium", + }, + { + number: 16, + title: "Improve page landmark and heading structure for screen reader navigation", + url: "https://github.com/sethjuarez/SignalBox/issues/16", + labels: ["good first issue", "signalbox-mvp", "frontend", "product-polish", "accessibility"], + column: "backlog", + priority: "medium", + }, +]; + +// ─── State persistence ─── + +function copilotHome() { + return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"); +} + +function getStatePath() { + return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", STATE_FILE); +} + +function defaultState() { + return { + repo: "sethjuarez/SignalBox", + updatedAt: new Date().toISOString(), + generation: Date.now(), + columns: COLUMNS, + issues: defaultIssues.map((issue, index) => ({ ...issue, order: index })), + }; +} + +function ensureStateDirectory() { + fs.mkdirSync(path.dirname(getStatePath()), { recursive: true }); +} + +function loadState() { + try { + return JSON.parse(fs.readFileSync(getStatePath(), "utf8")); + } catch { + return null; + } +} + +function saveState(state) { + ensureStateDirectory(); + fs.writeFileSync(getStatePath(), JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2)); +} + +function currentState() { + const state = loadState(); + if (state) return state; + const initial = defaultState(); + saveState(initial); + return initial; +} + +// ─── Issue operations ─── + +function moveIssue(issueNumber, column) { + if (!VALID_COLUMNS.has(column)) { + throw new CanvasError("invalid_column", `Column must be one of: ${COLUMNS.join(", ")}`); + } + const state = currentState(); + const issue = state.issues.find((i) => i.number === issueNumber); + if (!issue) { + throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`); + } + const prevColumn = issue.column; + issue.column = column; + issue.order = state.issues.filter((i) => i.column === column).length; + // Clear agent status when moved to done or backlog + if (column === "done" || column === "backlog") { + issue.agentActive = false; + issue.agentStatus = column === "done" ? "Complete" : ""; + } + saveState(state); + broadcast("state", currentState()); + return { issue, prevColumn }; +} + +function updateIssueStatus(issueNumber, status, logEntry) { + const state = currentState(); + const issue = state.issues.find((i) => i.number === issueNumber); + if (!issue) { + throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`); + } + // Don't update agent status on issues that have been reset to backlog + if (issue.column === "backlog") { + return issue; + } + if (status !== undefined) issue.agentStatus = status; + if (logEntry) { + if (!issue.logs) issue.logs = []; + issue.logs.push({ timestamp: new Date().toISOString(), message: logEntry }); + } + issue.agentActive = true; + saveState(state); + broadcast("state", currentState()); + return issue; +} + +function clearAgentStatus(issueNumber) { + const state = currentState(); + const issue = state.issues.find((i) => i.number === issueNumber); + if (!issue) return; + issue.agentActive = false; + saveState(state); + broadcast("state", currentState()); +} + +function replaceIssues(issues) { + const existing = currentState(); + const existingByNumber = new Map(existing.issues.map((i) => [i.number, i])); + const next = { + ...existing, + issues: issues + .filter((i) => i && Number.isInteger(i.number) && i.title) + .map((issue, idx) => { + const prev = existingByNumber.get(issue.number); + const labels = Array.isArray(issue.labels) + ? issue.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean) + : []; + return { + number: issue.number, + title: issue.title, + url: issue.url || `https://github.com/sethjuarez/SignalBox/issues/${issue.number}`, + labels, + column: VALID_COLUMNS.has(issue.column) ? issue.column : prev?.column || "backlog", + priority: issue.priority || prev?.priority || "medium", + order: Number.isInteger(issue.order) ? issue.order : prev?.order ?? idx, + }; + }), + }; + saveState(next); + broadcast("state", currentState()); + return currentState(); +} + +// ─── SSE ─── + +const sseClients = new Set(); + +function broadcast(event, data) { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of sseClients) res.write(msg); +} + +// ─── HTTP helpers ─── + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => resolve(body ? JSON.parse(body) : {})); + req.on("error", reject); + }); +} + +function json(res, code, data) { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +// ─── HTTP server ─── + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (url.pathname === "/events") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + res.write(`event: state\ndata: ${JSON.stringify(currentState())}\n\n`); + return; + } + + if (req.method === "GET" && url.pathname === "/api/state") { + json(res, 200, currentState()); + return; + } + + if (req.method === "POST" && url.pathname === "/api/move") { + const input = await readJson(req); + const { issue, prevColumn } = moveIssue(input.issue_number, input.column); + + // When an issue moves INTO "plan", send a prompt to the agent + if (input.column === "plan" && prevColumn !== "plan") { + if (issue.number === 35) { + // Fast path for demo — issue 35 is trivial, skip full analysis + session.send({ + prompt: `The accessibility kanban board just moved issue #35 ("Add aria-hidden to decorative SVG icons in AuthPage") into the Plan column. This is a simple fix — just add aria-hidden="true" to the two decorative blur divs and the Microsoft logo SVG in src/components/AuthPage.tsx. Use the kanban_update_status tool to post a brief status update ("Analyzing..."), then after a moment post the plan summary, then move the issue to "ready" using kanban_move_issue. Keep it quick — no need to read the GitHub issue or deeply analyze the codebase. The plan is: add aria-hidden="true" to lines ~47-48 (decorative background circles) and the SVG element at lines ~6-17.`, + }); + } else { + session.send({ + prompt: `The accessibility kanban board just moved issue #${issue.number} ("${issue.title}") into the Plan column. Please start planning the implementation for this issue in a background agent. Read the issue details from GitHub, analyze the codebase to understand what needs to change, and produce a concrete implementation plan. When planning is complete, move the issue to "ready" on the canvas using the move_issue canvas action.`, + }); + } + } + + json(res, 200, { issue, state: currentState() }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/update-status") { + const input = await readJson(req); + const issue = updateIssueStatus(input.issue_number, input.status, input.log); + if (input.done) clearAgentStatus(input.issue_number); + json(res, 200, { issue, state: currentState() }); + return; + } + + if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) { + const num = parseInt(url.pathname.split("/").pop(), 10); + const state = currentState(); + const issue = state.issues.find((i) => i.number === num); + if (!issue) { json(res, 404, { error: "not found" }); return; } + json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/reset") { + const s = defaultState(); + saveState(s); + broadcast("state", currentState()); + json(res, 200, currentState()); + return; + } + + if (url.pathname === "/") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +function getPort() { return server.address().port; } + +// ─── Canvas declaration ─── + +const canvas = createCanvas({ + id: "accessibility-kanban", + displayName: "Accessibility Kanban", + description: "Kanban board for triaging open SignalBox accessibility issues into backlog, plan, ready, implement, and done lanes. Moving an issue to plan triggers a background planning agent.", + actions: [ + { + name: "get_state", + description: "Get the current Kanban board state including all issues and their columns.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler() { + return currentState(); + }, + }, + { + name: "move_issue", + description: "Move an issue to a different column on the Kanban board.", + inputSchema: { + type: "object", + properties: { + issue_number: { type: "number", description: "GitHub issue number" }, + column: { type: "string", enum: COLUMNS, description: "Target column" }, + }, + required: ["issue_number", "column"], + additionalProperties: false, + }, + handler({ input }) { + const { issue } = moveIssue(input.issue_number, input.column); + return { issue, state: currentState() }; + }, + }, + { + name: "refresh_issues", + description: "Replace the board with fresh issue data supplied by the agent.", + inputSchema: { + type: "object", + properties: { + issues: { + type: "array", + items: { + type: "object", + properties: { + number: { type: "number" }, + title: { type: "string" }, + url: { type: "string" }, + labels: { type: "array", items: { oneOf: [{ type: "string" }, { type: "object", properties: { name: { type: "string" } }, required: ["name"] }] } }, + column: { type: "string", enum: COLUMNS }, + priority: { type: "string" }, + order: { type: "number" }, + }, + required: ["number", "title"], + additionalProperties: true, + }, + }, + }, + required: ["issues"], + additionalProperties: false, + }, + handler({ input }) { + return replaceIssues(input.issues); + }, + }, + { + name: "reset_state", + description: "Reset the board to the default issue list with everything in backlog.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler() { + const s = defaultState(); + saveState(s); + broadcast("state", currentState()); + return currentState(); + }, + }, + ], + open() { + const state = currentState(); + broadcast("state", state); + return { + url: `http://127.0.0.1:${getPort()}`, + title: "Accessibility Kanban", + status: `${state.issues.length} issues across ${COLUMNS.length} columns`, + }; + }, +}); + +// ─── Join session (tools + canvas) ─── + +const session = await joinSession({ + canvases: [canvas], + tools: [ + { + name: "kanban_move_issue", + description: "Move an issue on the accessibility Kanban board to a new column (backlog, plan, ready, implement, done). Use after completing a planning or implementation step to advance the issue.", + parameters: { + type: "object", + properties: { + issue_number: { type: "number", description: "GitHub issue number" }, + column: { type: "string", enum: COLUMNS, description: "Target column to move the issue to" }, + }, + required: ["issue_number", "column"], + }, + handler: async (args) => { + const { issue } = moveIssue(args.issue_number, args.column); + return JSON.stringify({ moved: true, issue, state: currentState() }); + }, + }, + { + name: "kanban_update_status", + description: "Update the agent status line and log on a Kanban card. Use this to report progress while planning or implementing an issue. The status appears under the card title and a glow indicates active work.", + parameters: { + type: "object", + properties: { + issue_number: { type: "number", description: "GitHub issue number" }, + status: { type: "string", description: "Short status text shown on the card (e.g. 'Reading issue...', 'Analyzing codebase...', 'Plan complete')" }, + log: { type: "string", description: "Detailed log entry appended to the issue's agent log (viewable in modal)" }, + done: { type: "boolean", description: "Set true to stop the active glow (agent finished working)" }, + }, + required: ["issue_number", "status"], + }, + handler: async (args) => { + const issue = updateIssueStatus(args.issue_number, args.status, args.log); + if (args.done) clearAgentStatus(args.issue_number); + return JSON.stringify({ updated: true, issue }); + }, + }, + ], +}); diff --git a/extensions/accessibility-kanban/package.json b/extensions/accessibility-kanban/package.json new file mode 100644 index 000000000..8015543b0 --- /dev/null +++ b/extensions/accessibility-kanban/package.json @@ -0,0 +1,9 @@ +{ + "name": "accessibility-kanban", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/accessibility-kanban/public/index.html b/extensions/accessibility-kanban/public/index.html new file mode 100644 index 000000000..92515bd17 --- /dev/null +++ b/extensions/accessibility-kanban/public/index.html @@ -0,0 +1,627 @@ + + + + + +Accessibility Kanban + + + + + +
+
+ + + +
+
+
+ + + + + + + diff --git a/extensions/color-orb/extension.mjs b/extensions/color-orb/extension.mjs new file mode 100644 index 000000000..1dd4c9d26 --- /dev/null +++ b/extensions/color-orb/extension.mjs @@ -0,0 +1,289 @@ +import http from "node:http"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +// In-memory state (ephemeral per provider process) +let currentColor = "#6c63ff"; +let logEntries = []; +const sseClients = new Set(); + +function broadcast(event, data) { + for (const res of sseClients) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } +} + +// --- Loopback HTTP server for the iframe --- +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(getHTML()); + return; + } + + if (req.method === "GET" && req.url === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + // Send current state immediately + res.write(`event: color\ndata: ${JSON.stringify({ color: currentColor })}\n\n`); + res.write(`event: log\ndata: ${JSON.stringify({ entries: logEntries })}\n\n`); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + return; + } + + if (req.method === "POST" && req.url === "/request-change") { + const entry = { time: new Date().toLocaleTimeString(), message: "🖱️ User clicked — requesting a color change..." }; + logEntries.push(entry); + broadcast("log", { entries: logEntries }); + if (session) { + session.send({ + prompt: "The user clicked the 'Ask Agent to Change Color' button on the Color Orb canvas. Pick a random, fun color and use the set_color canvas action to change the orb, then use log_message to tell them what color you chose and why.", + }); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === "POST" && req.url === "/clear-log") { + logEntries = []; + broadcast("log", { entries: logEntries }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); +}); + +let session; + +const canvas = createCanvas({ + id: "color-orb", + displayName: "Color Orb", + description: "An interactive orb whose color can be changed by the agent. The user clicks a button to request a color change, then the agent sets the new color.", + actions: [ + { + name: "set_color", + description: "Set the orb color. Accepts any valid CSS color (hex, named, rgb, hsl).", + inputSchema: { + type: "object", + properties: { + color: { type: "string", description: "CSS color value, e.g. '#ff6347' or 'tomato'" }, + }, + required: ["color"], + }, + handler({ input }) { + currentColor = input.color; + broadcast("color", { color: currentColor }); + return { color: currentColor }; + }, + }, + { + name: "log_message", + description: "Append a message to the canvas log area visible to the user.", + inputSchema: { + type: "object", + properties: { + message: { type: "string", description: "The message to display in the log" }, + }, + required: ["message"], + }, + handler({ input }) { + const entry = { time: new Date().toLocaleTimeString(), message: input.message }; + logEntries.push(entry); + broadcast("log", { entries: logEntries }); + return { ok: true }; + }, + }, + { + name: "clear_log", + description: "Clear all messages from the canvas log.", + inputSchema: { type: "object", properties: {} }, + handler() { + logEntries = []; + broadcast("log", { entries: logEntries }); + return { ok: true }; + }, + }, + ], + open({ instanceId }) { + return { + url: `http://127.0.0.1:${port}`, + title: "Color Orb", + status: "ready", + }; + }, +}); + +session = await joinSession({ canvases: [canvas] }); + +function getHTML() { + return ` + + + + + + + + +
+
+
color-orb
+
+
+
+
+ + +
+
+
+
waiting for input…
+
+
+ + + +`; +} diff --git a/extensions/color-orb/package-lock.json b/extensions/color-orb/package-lock.json new file mode 100644 index 000000000..fd2a9daea --- /dev/null +++ b/extensions/color-orb/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "color-orb", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "color-orb", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-7.tgz", + "integrity": "sha512-TczFrIaHH2sel6FM007H4FzT+Ipkj++I5u8Vx2ECWz9u24H7WOx/RpWcp6ExnSY1KSK1MtXaGcniAuqVi8Khaw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.55-7", + "@github/copilot-darwin-x64": "1.0.55-7", + "@github/copilot-linux-arm64": "1.0.55-7", + "@github/copilot-linux-x64": "1.0.55-7", + "@github/copilot-linuxmusl-arm64": "1.0.55-7", + "@github/copilot-linuxmusl-x64": "1.0.55-7", + "@github/copilot-win32-arm64": "1.0.55-7", + "@github/copilot-win32-x64": "1.0.55-7" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-7.tgz", + "integrity": "sha512-QReU4F5+W0x/Nuc6qO+xYPeNnRjuHIIAeMBc1S+RFQ0T+YWynxRzNHGs9ZkUiIcLJ1F/y8GDq6sq7760Cn+onQ==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-7.tgz", + "integrity": "sha512-qQ0d+XyvIPbNiaIydHBSCTQfWK5s0x1XnlrUKSzadgOnsFobGeldLSKtB159zJEiz0F/in5ythiUGJjWoAQVrA==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-7.tgz", + "integrity": "sha512-+2zlHahK3fUfkrnlHqbdQsZMPZwRfchoTxDZd9UHbEhQF7eNLzYN+7frWs6AZujU+h/1i92+mcLT18AQXI3KxQ==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-7.tgz", + "integrity": "sha512-SGmvWcJHIKDIsjYZdFQloGw3Re6r2N1Zv1VuB1yV1ClVqfG5i5pTvai6vzX8d3WgGgRzrkLksDrzZKR27zJZ7A==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-7.tgz", + "integrity": "sha512-rJkZLvz4KeGoLgyX6gcONgTNfFxeoQvN4jaAXlbD1nFP3hJbLTuY0CB4fBHmZWktrPkRL/j5aDGxrcIcl+Xg3A==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-7.tgz", + "integrity": "sha512-uPb08qgJHY1QW2YhA1OBJ9PB0CDwCvtuttWbeZ+AW+qfFVsvBpARU1cdEl/xT4IXMhBFoJiePv3BnLGjVZtoWA==", + "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.0-beta.9", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz", + "integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.55-5", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-7.tgz", + "integrity": "sha512-mb4Sg2sJjmK9Rq8XCRuhoIOjUScB5p2Ct9ZtTbC3ipvONWMOMjYPbLvC8K9GAHcYcHLdv98hvzv3+qjBhb5tZQ==", + "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.55-7", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-7.tgz", + "integrity": "sha512-GL9jAtkn2Kx4IO9ZfTiMC3LFd539KuuOx3uOIKciWKMuCvcfct0rdVkXlDr+EnrmPzu1A4PavcJ0RScpI39jUQ==", + "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/color-orb/package.json b/extensions/color-orb/package.json new file mode 100644 index 000000000..d3b328485 --- /dev/null +++ b/extensions/color-orb/package.json @@ -0,0 +1,9 @@ +{ + "name": "color-orb", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/diagram-viewer/extension.mjs b/extensions/diagram-viewer/extension.mjs new file mode 100644 index 000000000..28c4d3403 --- /dev/null +++ b/extensions/diagram-viewer/extension.mjs @@ -0,0 +1,390 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Per-instance state (ephemeral, lives in memory for session lifetime) +const instances = new Map(); + +function getInstance(instanceId) { + if (!instances.has(instanceId)) { + instances.set(instanceId, { + currentView: null, + history: [], + selectedNodeId: null, + token: crypto.randomBytes(16).toString("hex"), + }); + } + return instances.get(instanceId); +} + +function getCurrentView(inst) { + return inst.currentView; +} + +function pushView(inst, view) { + if (inst.currentView) { + inst.history.push(inst.currentView); + } + inst.currentView = view; + inst.selectedNodeId = null; +} + +function replaceView(inst, view) { + inst.currentView = view; + inst.selectedNodeId = null; +} + +function popView(inst) { + if (inst.history.length === 0) return null; + inst.currentView = inst.history.pop(); + inst.selectedNodeId = null; + return inst.currentView; +} + +// SSE clients per instance +const sseClients = new Map(); + +function broadcast(instanceId, event, data) { + const clients = sseClients.get(instanceId); + if (!clients) return; + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of clients) { + res.write(msg); + } +} + +// Broadcast the full view state to the iframe +function broadcastView(instanceId, inst) { + const view = getCurrentView(inst); + broadcast(instanceId, "view", { + ...view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + }); +} + +// HTTP helpers +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => resolve(body ? JSON.parse(body) : {})); + req.on("error", reject); + }); +} + +function json(res, code, data) { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +// HTTP server +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const token = url.searchParams.get("token"); + const instanceId = url.searchParams.get("instance"); + + // Serve the HTML page + if (req.method === "GET" && url.pathname === "/") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")); + return; + } + + // SSE endpoint + if (req.method === "GET" && url.pathname === "/events") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set()); + sseClients.get(instanceId).add(res); + req.on("close", () => { + const clients = sseClients.get(instanceId); + if (clients) clients.delete(res); + }); + // Send current view state immediately + const inst = getInstance(instanceId); + if (inst.currentView) { + const view = getCurrentView(inst); + res.write(`event: view\ndata: ${JSON.stringify({ + ...view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat([view.title]), + })}\n\n`); + if (inst.selectedNodeId) { + res.write(`event: select\ndata: ${JSON.stringify({ nodeId: inst.selectedNodeId })}\n\n`); + } + } + return; + } + + // API: get full state + if (req.method === "GET" && url.pathname === "/api/state") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + json(res, 200, { + view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + selectedNodeId: inst.selectedNodeId, + }); + return; + } + + // API: node clicked — triggers drill-down + if (req.method === "POST" && url.pathname === "/api/click") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const { nodeId } = await readJson(req); + const inst = getInstance(instanceId); + inst.selectedNodeId = nodeId; + broadcast(instanceId, "select", { nodeId }); + + // Send prompt to agent to drill into the clicked node + const view = getCurrentView(inst); + const node = view?.diagram?.nodes?.find((n) => n.id === nodeId); + if (node && session) { + const diagramContext = view.diagram.nodes.map((n) => n.label).join(", "); + session.send({ + prompt: `The user clicked on the "${node.label}" node in the Diagram Explorer canvas (id: "${node.id}", type: "${node.type || "default"}", description: "${node.description || "none"}"). The current diagram is "${view.title}" which contains: ${diagramContext}. + +Do NOT explain in chat. Instead, use the canvas actions to respond visually: +1. Use the render_diagram action with mode "push" to show a detailed sub-diagram of "${node.label}" — break it into its internal components, sub-systems, or key parts with their relationships. +2. Use the show_explanation action to display a brief explanation panel on the canvas. + +If you cannot create a meaningful sub-diagram (e.g. the node is already a leaf concept), use show_explanation to provide a detailed description on the canvas instead, without rendering a new diagram.`, + }); + } + + json(res, 200, { ok: true, selectedNodeId: nodeId }); + return; + } + + // API: navigate back + if (req.method === "POST" && url.pathname === "/api/back") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const inst = getInstance(instanceId); + const prev = popView(inst); + if (prev) { + broadcastView(instanceId, inst); + } + json(res, 200, { ok: true, view: prev }); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +function validateToken(instanceId, token) { + const inst = instances.get(instanceId); + return inst && inst.token === token; +} + +const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); +}); + +// Canvas declaration +const canvas = createCanvas({ + id: "diagram", + displayName: "Diagram Explorer", + description: + "Interactive diagram for exploring architecture, data flow, and relationships. Render nodes and edges, then click any node to get a detailed explanation from the agent.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Optional title for the initial diagram" }, + }, + }, + actions: [ + { + name: "render_diagram", + description: + "Render an interactive diagram with nodes and edges. Use mode 'push' to drill into a node (adds to history so user can navigate back), or 'replace' (default) to update the current view in place.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Diagram title" }, + nodes: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", description: "Unique node identifier" }, + label: { type: "string", description: "Display label" }, + description: { + type: "string", + description: "Brief description shown on hover and used when drilling in", + }, + type: { + type: "string", + description: "Node type for color coding (e.g. 'service', 'database', 'ui', 'api', 'config', 'external')", + }, + }, + required: ["id", "label"], + }, + }, + edges: { + type: "array", + items: { + type: "object", + properties: { + from: { type: "string", description: "Source node id" }, + to: { type: "string", description: "Target node id" }, + label: { type: "string", description: "Optional edge label" }, + }, + required: ["from", "to"], + }, + }, + mode: { + type: "string", + enum: ["push", "replace"], + description: "Navigation mode. 'push' saves current view to history (for drill-down). 'replace' updates in place (default).", + }, + explanation: { + type: "object", + properties: { + title: { type: "string", description: "Explanation panel title" }, + text: { type: "string", description: "Explanation text (plain text)" }, + }, + description: "Optional explanation to show alongside the diagram", + }, + }, + required: ["nodes", "edges"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = { + title: input.title || "Diagram", + diagram: { title: input.title || "Diagram", nodes: input.nodes, edges: input.edges }, + explanation: input.explanation || null, + selectedNodeId: null, + }; + + if (input.mode === "push") { + pushView(inst, view); + } else { + replaceView(inst, view); + } + + broadcastView(instanceId, inst); + return { ok: true, nodeCount: input.nodes.length, edgeCount: input.edges.length, historyDepth: inst.history.length }; + }, + }, + { + name: "show_explanation", + description: + "Display an explanation panel on the canvas alongside the current diagram. Use this to provide context about the current view or a clicked node without changing the diagram.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Explanation panel title" }, + text: { type: "string", description: "Explanation content (plain text, can include line breaks)" }, + }, + required: ["title", "text"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + if (view) { + view.explanation = { title: input.title, text: input.text }; + broadcast(instanceId, "explanation", view.explanation); + } + return { ok: true }; + }, + }, + { + name: "get_state", + description: + "Get the current diagram state including which node the user last clicked and the history depth.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler({ instanceId }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + const selectedNode = inst.selectedNodeId + ? view?.diagram?.nodes?.find((n) => n.id === inst.selectedNodeId) + : null; + return { + currentView: view, + selectedNodeId: inst.selectedNodeId, + selectedNode: selectedNode || null, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + }; + }, + }, + { + name: "highlight_node", + description: "Highlight a specific node in the diagram (e.g. while explaining it).", + inputSchema: { + type: "object", + properties: { + nodeId: { type: "string", description: "The node id to highlight" }, + }, + required: ["nodeId"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + inst.selectedNodeId = input.nodeId; + broadcast(instanceId, "select", { nodeId: input.nodeId }); + return { ok: true, highlightedNodeId: input.nodeId }; + }, + }, + { + name: "clear", + description: "Clear the diagram canvas and all history.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler({ instanceId }) { + const inst = getInstance(instanceId); + inst.currentView = null; + inst.history = []; + inst.selectedNodeId = null; + broadcast(instanceId, "clear", {}); + return { ok: true }; + }, + }, + ], + open({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + return { + url: `http://127.0.0.1:${port}?instance=${instanceId}&token=${inst.token}`, + title: input?.title || "Diagram Explorer", + status: view + ? `${view.diagram.nodes.length} nodes` + : "Ready", + }; + }, +}); + +let session = await joinSession({ canvases: [canvas] }); diff --git a/extensions/diagram-viewer/package-lock.json b/extensions/diagram-viewer/package-lock.json new file mode 100644 index 000000000..764037545 --- /dev/null +++ b/extensions/diagram-viewer/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "diagram-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "diagram-viewer", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.55", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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.0-beta.9", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz", + "integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.55-5", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/diagram-viewer/package.json b/extensions/diagram-viewer/package.json new file mode 100644 index 000000000..c5124d57f --- /dev/null +++ b/extensions/diagram-viewer/package.json @@ -0,0 +1,9 @@ +{ + "name": "diagram-viewer", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/diagram-viewer/public/index.html b/extensions/diagram-viewer/public/index.html new file mode 100644 index 000000000..a5c5a920f --- /dev/null +++ b/extensions/diagram-viewer/public/index.html @@ -0,0 +1,721 @@ + + + + + +Diagram Explorer + + + + + +
+ + + +
+ +
+
+
+
+

Ask Copilot about architecture or any topic, and an interactive diagram will appear here. Click nodes to drill in.

+
+ +
+
+
+
Click to drill in
+
+
+ + Agent thinking… +
+
+ +
+
+

Explanation

+ +
+
+
+
+ + + + diff --git a/extensions/feedback-themes/data/signals.json b/extensions/feedback-themes/data/signals.json new file mode 100644 index 000000000..8135457f3 --- /dev/null +++ b/extensions/feedback-themes/data/signals.json @@ -0,0 +1,244 @@ +{ + "meta": { + "description": "Synthetic feedback signals for SignalBox theme exploration. These are demo data derived from fictional customer research scenarios.", + "generatedAt": "2026-05-28" + }, + "themes": [ + { + "id": "workflow-automation", + "label": "Workflow Automation", + "description": "Signals about automating repetitive tasks, scheduling recurring operations, and reducing manual overhead in day-to-day workflows.", + "aliases": ["workflow automation", "reporting cadence", "admin efficiency", "scheduled tasks", "recurring operations"] + }, + { + "id": "mobile-usability", + "label": "Mobile Usability", + "description": "Feedback on mobile experience gaps — density of information on small screens, touch interactions, and on-the-go decision making.", + "aliases": ["mobile usability", "alert prioritization", "frontline decision making", "responsive design", "touch interaction"] + }, + { + "id": "data-governance", + "label": "Data Governance & Permissions", + "description": "Concerns around sharing confidence, permission transparency, and ensuring sensitive data stays protected during collaboration.", + "aliases": ["permissions transparency", "data governance", "sharing confidence", "access control", "data privacy"] + }, + { + "id": "onboarding-setup", + "label": "Onboarding & Setup", + "description": "Pain points in first-run experiences, initial configuration complexity, and time-to-value for new users and teams.", + "aliases": ["onboarding", "first-run experience", "setup complexity", "time to value", "getting started"] + }, + { + "id": "performance-reliability", + "label": "Performance & Reliability", + "description": "Issues with load times, API timeouts, data sync delays, and system reliability under normal and peak usage.", + "aliases": ["performance", "load times", "reliability", "api timeouts", "data sync", "latency"] + }, + { + "id": "integration-ecosystem", + "label": "Integration Ecosystem", + "description": "Requests for third-party connectors, API extensibility, webhook support, and interoperability with existing toolchains.", + "aliases": ["integrations", "third-party connectors", "api extensibility", "webhook support", "ecosystem"] + } + ], + "signals": [ + { + "id": "sig-001", + "source": "user-interview", + "customer": "Northstar Analytics Cooperative", + "title": "Admins need scheduled exports for recurring reviews", + "description": "A fictional operations admin described rebuilding the same export every week before leadership review. The core need is a recurring delivery flow with clear ownership and failure visibility.", + "impact": "high", + "themes": ["workflow-automation"], + "submittedBy": "Sarah Chen", + "createdAt": "2026-04-12" + }, + { + "id": "sig-002", + "source": "customer-call", + "customer": "Blue Harbor Retail Group", + "title": "Field managers need faster mobile triage", + "description": "A fictional district manager said alert detail pages are useful on desktop but too dense during store visits. They want a compact mobile summary that highlights severity, affected locations, and the next best action.", + "impact": "medium", + "themes": ["mobile-usability"], + "submittedBy": "Marcus Rivera", + "createdAt": "2026-04-15" + }, + { + "id": "sig-003", + "source": "support-ticket", + "customer": "Cedar Labs Education", + "title": "Analysts need clearer permission boundaries", + "description": "A fictional analytics lead hesitated to share dashboards because the UI did not clearly explain which sensitive fields were excluded for external reviewers. The theme is confidence-building around governed collaboration.", + "impact": "high", + "themes": ["data-governance"], + "submittedBy": "Priya Patel", + "createdAt": "2026-04-18" + }, + { + "id": "sig-004", + "source": "sales-note", + "customer": "Verdant Supply Co", + "title": "Procurement team blocked by slow initial setup", + "description": "Prospect's IT team estimated 3 weeks to configure SSO and role mappings. They need a guided wizard that reduces setup from weeks to hours, with clear progress indicators and rollback options.", + "impact": "high", + "themes": ["onboarding-setup"], + "submittedBy": "James O'Brien", + "createdAt": "2026-04-20" + }, + { + "id": "sig-005", + "source": "support-ticket", + "customer": "Apex Manufacturing", + "title": "Dashboard timeouts during month-end reporting", + "description": "Multiple users reported 30-second load times and occasional gateway timeouts when running aggregate queries across all business units during month-end close. Affects executive visibility into financials.", + "impact": "high", + "themes": ["performance-reliability"], + "submittedBy": "Lisa Chang", + "createdAt": "2026-04-22" + }, + { + "id": "sig-006", + "source": "customer-call", + "customer": "Meridian Health Systems", + "title": "Need Salesforce integration for patient outreach tracking", + "description": "Clinical ops team manually exports engagement data to upload into Salesforce campaigns. They need a native connector or webhook that syncs patient touchpoints in near real-time.", + "impact": "medium", + "themes": ["integration-ecosystem"], + "submittedBy": "David Park", + "createdAt": "2026-04-25" + }, + { + "id": "sig-007", + "source": "user-interview", + "customer": "Northstar Analytics Cooperative", + "title": "Approval chains block time-sensitive reports", + "description": "Reports that require manager sign-off before distribution often miss their deadline. The team wants conditional auto-approval for recurring reports that haven't changed scope.", + "impact": "medium", + "themes": ["workflow-automation"], + "submittedBy": "Sarah Chen", + "createdAt": "2026-05-01" + }, + { + "id": "sig-008", + "source": "teams-conversation", + "customer": "Blue Harbor Retail Group", + "title": "Push notifications dismissed too easily on mobile", + "description": "Store managers reported that critical alerts are visually identical to informational ones. They swipe-dismiss high-priority alerts because there's no visual urgency differentiation on the lock screen.", + "impact": "high", + "themes": ["mobile-usability"], + "submittedBy": "Marcus Rivera", + "createdAt": "2026-05-03" + }, + { + "id": "sig-009", + "source": "user-interview", + "customer": "Cedar Labs Education", + "title": "External partners confused by permission error messages", + "description": "Partner reviewers see generic 'Access Denied' screens with no explanation of what they lack access to or who to contact. They need contextual guidance that preserves security while reducing friction.", + "impact": "medium", + "themes": ["data-governance"], + "submittedBy": "Priya Patel", + "createdAt": "2026-05-05" + }, + { + "id": "sig-010", + "source": "customer-call", + "customer": "Solaris Energy", + "title": "New team members take too long to become productive", + "description": "Engineering managers say it takes 2-3 weeks for new hires to navigate the system confidently. They want role-based onboarding paths with interactive tutorials rather than static documentation.", + "impact": "medium", + "themes": ["onboarding-setup"], + "submittedBy": "Amanda Foster", + "createdAt": "2026-05-07" + }, + { + "id": "sig-011", + "source": "support-ticket", + "customer": "Pinnacle Financial", + "title": "Real-time data sync drops events under high load", + "description": "During market open hours, the event stream occasionally drops updates, causing stale portfolio values. They need guaranteed delivery or at minimum a visible staleness indicator.", + "impact": "high", + "themes": ["performance-reliability"], + "submittedBy": "Robert Kim", + "createdAt": "2026-05-09" + }, + { + "id": "sig-012", + "source": "sales-note", + "customer": "Atlas Logistics", + "title": "Must integrate with ServiceNow for IT ticket routing", + "description": "Prospect requires alerts to automatically create ServiceNow incidents with proper categorization. Without this integration, their compliance team won't approve the vendor.", + "impact": "high", + "themes": ["integration-ecosystem"], + "submittedBy": "Jennifer Walsh", + "createdAt": "2026-05-11" + }, + { + "id": "sig-013", + "source": "teams-conversation", + "customer": "Verdant Supply Co", + "title": "Bulk user provisioning needs CSV import", + "description": "IT admin has 200+ users to onboard and the current one-by-one flow is untenable. They need batch import with validation preview and error handling.", + "impact": "medium", + "themes": ["onboarding-setup", "workflow-automation"], + "submittedBy": "Thomas Wright", + "createdAt": "2026-05-13" + }, + { + "id": "sig-014", + "source": "customer-call", + "customer": "Apex Manufacturing", + "title": "API rate limits too restrictive for ETL pipelines", + "description": "Their data engineering team hits rate limits during nightly batch syncs. Current limits of 100 req/min are insufficient for their 50K-record nightly ETL job.", + "impact": "medium", + "themes": ["performance-reliability", "integration-ecosystem"], + "submittedBy": "Lisa Chang", + "createdAt": "2026-05-15" + }, + { + "id": "sig-015", + "source": "user-interview", + "customer": "Meridian Health Systems", + "title": "Mobile app crashes when offline then reconnecting", + "description": "Clinicians in areas with spotty WiFi lose unsaved form data when the app crashes on network transition. They need offline-capable data entry with background sync.", + "impact": "high", + "themes": ["mobile-usability", "performance-reliability"], + "submittedBy": "David Park", + "createdAt": "2026-05-17" + }, + { + "id": "sig-016", + "source": "support-ticket", + "customer": "Solaris Energy", + "title": "Sharing a dashboard should show a permission preview", + "description": "Before sharing, users want to see exactly what the recipient will see — including which widgets will be hidden and which data will be masked. Current share dialog gives no preview.", + "impact": "medium", + "themes": ["data-governance"], + "submittedBy": "Amanda Foster", + "createdAt": "2026-05-19" + }, + { + "id": "sig-017", + "source": "sales-note", + "customer": "Pinnacle Financial", + "title": "Need webhook notifications for compliance audit trail", + "description": "Compliance team requires real-time webhook callbacks whenever sensitive data is accessed or exported. This is a hard requirement for their SOC 2 audit.", + "impact": "high", + "themes": ["integration-ecosystem", "data-governance"], + "submittedBy": "Robert Kim", + "createdAt": "2026-05-21" + }, + { + "id": "sig-018", + "source": "other", + "customer": "Atlas Logistics", + "title": "Automated alert escalation when no action taken", + "description": "If a critical alert isn't acknowledged within 15 minutes, it should auto-escalate to the next person in the chain. Current system only sends one notification with no follow-up.", + "impact": "high", + "themes": ["workflow-automation"], + "submittedBy": "Jennifer Walsh", + "createdAt": "2026-05-23" + } + ] +} diff --git a/extensions/feedback-themes/extension.mjs b/extensions/feedback-themes/extension.mjs new file mode 100644 index 000000000..e489fa0d9 --- /dev/null +++ b/extensions/feedback-themes/extension.mjs @@ -0,0 +1,196 @@ +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ─── Load fixture data ─── + +const fixtureRaw = JSON.parse( + fs.readFileSync(path.join(__dirname, "data", "signals.json"), "utf8") +); +const THEMES = fixtureRaw.themes; +const SIGNALS = fixtureRaw.signals; + +// ─── Theme computation ─── + +function computeThemeGroups() { + return THEMES.map((theme) => { + const signals = SIGNALS.filter((s) => s.themes.includes(theme.id)); + const impactOrder = { high: 3, medium: 2, low: 1 }; + const maxImpact = signals.reduce( + (max, s) => (impactOrder[s.impact] > impactOrder[max] ? s.impact : max), + "low" + ); + const sources = [...new Set(signals.map((s) => s.source))]; + const customers = [...new Set(signals.map((s) => s.customer))]; + return { + ...theme, + signalCount: signals.length, + maxImpact, + sources, + customers, + signals, + }; + }).sort((a, b) => { + const impactOrder = { high: 3, medium: 2, low: 1 }; + if (impactOrder[b.maxImpact] !== impactOrder[a.maxImpact]) { + return impactOrder[b.maxImpact] - impactOrder[a.maxImpact]; + } + return b.signalCount - a.signalCount; + }); +} + +function getState() { + const groups = computeThemeGroups(); + return { + totalSignals: SIGNALS.length, + totalThemes: THEMES.length, + themes: groups, + }; +} + +// ─── SSE ─── + +const sseClients = new Set(); + +function broadcast(event, data) { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of sseClients) res.write(msg); +} + +// ─── HTTP helpers ─── + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => resolve(body ? JSON.parse(body) : {})); + req.on("error", reject); + }); +} + +function json(res, code, data) { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +// ─── HTTP server ─── + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + res.write(`event: state\ndata: ${JSON.stringify(getState())}\n\n`); + return; + } + + if (req.method === "GET" && url.pathname === "/api/state") { + json(res, 200, getState()); + return; + } + + if (req.method === "POST" && url.pathname === "/api/explore-theme") { + const { themeId } = await readJson(req); + const theme = computeThemeGroups().find((t) => t.id === themeId); + if (!theme) { + json(res, 404, { error: "Theme not found" }); + return; + } + // Trigger the agent to start a session exploring this theme + session.send({ + prompt: `The user wants to explore the "${theme.label}" feedback theme in depth. This theme has ${theme.signalCount} signals across customers: ${theme.customers.join(", ")}. Maximum impact: ${theme.maxImpact}. + +Theme description: ${theme.description} + +Signals in this theme: +${theme.signals.map((s) => `- [${s.impact.toUpperCase()}] "${s.title}" (${s.customer}): ${s.description}`).join("\n")} + +Please help the user explore this theme. Summarize the key patterns, identify what product changes would address these signals, and suggest next steps. Ask the user what aspect they'd like to dig into.`, + }); + json(res, 200, { ok: true, theme: theme.label }); + return; + } + + if (url.pathname === "/") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8") + ); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +function getPort() { + return server.address().port; +} + +// ─── Canvas declaration ─── + +const canvas = createCanvas({ + id: "feedback-themes", + displayName: "Feedback Themes", + description: + "Explore SignalBox feedback grouped into themes. Shows signal counts, impact levels, and sources for each theme. Use to identify patterns and start deep-dive sessions on specific themes.", + actions: [ + { + name: "get_state", + description: + "Get all feedback themes with their grouped signals, impact levels, and source breakdown.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler() { + return getState(); + }, + }, + { + name: "explore_theme", + description: + "Get detailed information about a specific feedback theme including all associated signals.", + inputSchema: { + type: "object", + properties: { + theme_id: { + type: "string", + description: + "Theme identifier (workflow-automation, mobile-usability, data-governance, onboarding-setup, performance-reliability, integration-ecosystem)", + }, + }, + required: ["theme_id"], + additionalProperties: false, + }, + handler({ input }) { + const theme = computeThemeGroups().find((t) => t.id === input.theme_id); + if (!theme) { + throw new CanvasError("not_found", `Theme "${input.theme_id}" not found`); + } + return theme; + }, + }, + ], + open() { + const state = getState(); + broadcast("state", state); + return { + url: `http://127.0.0.1:${getPort()}`, + title: "Feedback Themes", + status: `${state.totalSignals} signals across ${state.totalThemes} themes`, + }; + }, +}); + +// ─── Join session ─── + +const session = await joinSession({ canvases: [canvas] }); diff --git a/extensions/feedback-themes/package-lock.json b/extensions/feedback-themes/package-lock.json new file mode 100644 index 000000000..9cb500af3 --- /dev/null +++ b/extensions/feedback-themes/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "feedback-themes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "feedback-themes", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.55", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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.0-beta.9", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz", + "integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.55-5", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/feedback-themes/package.json b/extensions/feedback-themes/package.json new file mode 100644 index 000000000..778b9a58c --- /dev/null +++ b/extensions/feedback-themes/package.json @@ -0,0 +1,9 @@ +{ + "name": "feedback-themes", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/feedback-themes/public/index.html b/extensions/feedback-themes/public/index.html new file mode 100644 index 000000000..ed22a2b0d --- /dev/null +++ b/extensions/feedback-themes/public/index.html @@ -0,0 +1,419 @@ + + + + + +Feedback Themes + + + + + +
+
+

Feedback Themes

+

Synthetic signals grouped by theme · click to explore

+
+
+
Signals
+
Themes
+
High Impact
+
+
+

Loading themes…

+
+
+ + + + diff --git a/extensions/gesture-review/extension.mjs b/extensions/gesture-review/extension.mjs new file mode 100644 index 000000000..94eae7ff2 --- /dev/null +++ b/extensions/gesture-review/extension.mjs @@ -0,0 +1,1237 @@ +import http from "node:http"; +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +// This file lives inside the repo worktree, so its directory is a safe cwd for +// git/gh regardless of where the extension host process was launched from. +const extensionDir = dirname(fileURLToPath(import.meta.url)); + +// In-memory state +let currentPR = null; +let prList = []; +let gestureState = "idle"; // idle | detecting | approved | rejected +let lastDecision = null; +const sseClients = new Set(); +let loadPRsPromise = null; // in-flight guard for loadOpenPRs +let cachedHTML = null; // cached HTML string + +function broadcast(event, data) { + for (const res of sseClients) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } +} + +// --- Load open PRs from the repo via the gh CLI --- +function shortDescription(body) { + if (!body) return ""; + // First non-empty, non-heading line, trimmed to a reasonable length. + const line = body + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l && !l.startsWith("#")); + if (!line) return ""; + return line.length > 140 ? line.slice(0, 137) + "..." : line; +} + +function loadOpenPRs() { + // De-dupe: return existing in-flight promise if one is running + if (loadPRsPromise) return loadPRsPromise; + + loadPRsPromise = new Promise((resolve) => { + execFile( + "gh", + [ + "pr", + "list", + "--state", + "open", + "--limit", + "20", + "--json", + "number,title,author,additions,deletions,body", + ], + { cwd: extensionDir, maxBuffer: 1024 * 1024 }, + (err, stdout) => { + loadPRsPromise = null; + if (err) { + console.error("gesture-review: failed to load PRs:", err.message); + resolve(false); + return; + } + try { + const raw = JSON.parse(stdout); + prList = raw.map((pr) => ({ + title: pr.title, + number: pr.number, + author: pr.author?.login || "unknown", + description: shortDescription(pr.body), + additions: pr.additions || 0, + deletions: pr.deletions || 0, + })); + // Keep currentPR pointing at a still-open PR if possible. + if (currentPR) { + currentPR = prList.find((p) => p.number === currentPR.number) || null; + } + broadcast("prlist", prList); + if (currentPR) broadcast("pr", currentPR); + resolve(true); + } catch (e) { + console.error("gesture-review: failed to parse PRs:", e.message); + resolve(false); + } + }, + ); + }); + + return loadPRsPromise; +} + +// --- Loopback HTTP server for the iframe --- +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/") { + if (!cachedHTML) cachedHTML = getHTML(); + res.writeHead(200, { + "Content-Type": "text/html", + "Cache-Control": "no-cache", + }); + res.end(cachedHTML); + return; + } + + if (req.method === "GET" && req.url === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + // Send current state immediately + res.write(`event: prlist\ndata: ${JSON.stringify(prList)}\n\n`); + if (currentPR) { + res.write(`event: pr\ndata: ${JSON.stringify(currentPR)}\n\n`); + } + res.write(`event: state\ndata: ${JSON.stringify({ state: gestureState })}\n\n`); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + return; + } + + if (req.method === "POST" && req.url === "/select-pr") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const { number } = JSON.parse(body); + const pr = prList.find((p) => p.number === number); + if (pr) { + currentPR = pr; + gestureState = "idle"; + broadcast("pr", currentPR); + broadcast("state", { state: "idle" }); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + if (req.method === "POST" && req.url === "/gesture-decision") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const { decision } = JSON.parse(body); + gestureState = decision; // "approved" or "rejected" + lastDecision = { decision, pr: currentPR, timestamp: Date.now() }; + broadcast("state", { state: gestureState }); + + if (session && currentPR) { + const action = decision === "approved" ? "approve" : "reject"; + session.send({ + prompt: `The user gave a thumbs ${decision === "approved" ? "up" : "down"} gesture to ${action} PR #${currentPR.number} ("${currentPR.title}" by ${currentPR.author}). Please ${action} this pull request accordingly.`, + }); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, decision })); + }); + return; + } + + if (req.method === "POST" && req.url === "/refresh") { + loadOpenPRs().then(() => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, count: prList.length })); + }); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); +}); + +let session; + +const canvas = createCanvas({ + id: "gesture-review", + displayName: "Gesture PR Review", + description: + "Interactive PR review using hand gestures. Shows a live camera feed and detects thumbs up (approve) or thumbs down (reject) via MediaPipe hand tracking.", + actions: [ + { + name: "show_pr", + description: + "Display a PR for the user to gesture-review. Shows PR info and activates gesture detection.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "PR title" }, + number: { type: "number", description: "PR number" }, + author: { type: "string", description: "PR author username" }, + description: { + type: "string", + description: "Short PR description", + }, + additions: { + type: "number", + description: "Lines added", + }, + deletions: { + type: "number", + description: "Lines deleted", + }, + }, + required: ["title", "number", "author"], + }, + handler({ input }) { + currentPR = { + title: input.title, + number: input.number, + author: input.author, + description: input.description || "", + additions: input.additions || 0, + deletions: input.deletions || 0, + }; + // Add to list if not already there + if (!prList.find((p) => p.number === currentPR.number)) { + prList.push(currentPR); + broadcast("prlist", prList); + } + gestureState = "idle"; + broadcast("pr", currentPR); + broadcast("state", { state: "idle" }); + return { ok: true, pr: currentPR }; + }, + }, + { + name: "get_status", + description: + "Returns current gesture detection state and last decision made.", + inputSchema: { type: "object", properties: {} }, + handler() { + return { + gestureState, + currentPR, + lastDecision, + }; + }, + }, + ], + open({ instanceId }) { + // Refresh open PRs each time the canvas is opened so the drawer is current. + loadOpenPRs(); + return { + url: `http://127.0.0.1:${port}`, + title: "Gesture PR Review", + status: "ready", + }; + }, +}); + +session = await joinSession({ canvases: [canvas] }); + +// Populate the drawer with open PRs as soon as the extension starts. +loadOpenPRs(); + +function getHTML() { + return ` + + + + + + + + + + + + +
+ + +
+ + +
+
+
Initializing camera...
+
+
+ + + + + +
+ 👋 + Waiting for a PR to review... + Ask the agent to show a PR +
+ + +
+ +
+ +
Initializing camera...
+
+ + + +`; +} diff --git a/extensions/gesture-review/package-lock.json b/extensions/gesture-review/package-lock.json new file mode 100644 index 000000000..de10bc66d --- /dev/null +++ b/extensions/gesture-review/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "gesture-review", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gesture-review", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.55", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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.0-beta.9", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz", + "integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.55-5", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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.55", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/gesture-review/package.json b/extensions/gesture-review/package.json new file mode 100644 index 000000000..4e23e484c --- /dev/null +++ b/extensions/gesture-review/package.json @@ -0,0 +1,9 @@ +{ + "name": "gesture-review", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + } +} diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs new file mode 100644 index 000000000..66e6da89e --- /dev/null +++ b/extensions/where-was-i/extension.mjs @@ -0,0 +1,747 @@ +// Extension: where-was-i +// Interrupt Recovery canvas — helps developers resume mental context after interruption. + +import { createServer } from "node:http"; +import { execFile } from "node:child_process"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; + +const servers = new Map(); +const sseClients = new Map(); // instanceId → Set +const contextCache = new Map(); // instanceId → contextData + +const isWindows = process.platform === "win32"; + +// Derive repo root from extension location (.github/extensions/where-was-i/) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = join(__dirname, "..", "..", ".."); + +// --- Shell helpers --- + +function run(cmd, cwd) { + const shell = isWindows ? "powershell" : "bash"; + const args = isWindows + ? ["-NoProfile", "-NoLogo", "-Command", cmd] + : ["-c", cmd]; + return new Promise((resolve) => { + execFile(shell, args, { cwd, timeout: 15000, maxBuffer: 1024 * 256 }, (err, stdout) => { + resolve(err ? "" : (stdout || "").trim()); + }); + }); +} + +async function gatherContext(cwd) { + cwd = cwd || REPO_ROOT; + const authorCmd = isWindows + ? 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"' + : 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"'; + const suppressErr = isWindows ? "2>$null" : "2>/dev/null"; + + const [branch, log, status, diff, prs, issues] = await Promise.all([ + run("git branch --show-current", cwd), + run(authorCmd, cwd), + run("git status --short", cwd), + run("git diff --stat", cwd), + run(`gh pr list --author=@me --state=open --limit=10 --json number,title,url,updatedAt,comments ${suppressErr}`, cwd), + run(`gh issue list --assignee=@me --state=open --limit=10 --json number,title,url,updatedAt ${suppressErr}`, cwd), + ]); + + let parsedPrs = []; + let parsedIssues = []; + try { parsedPrs = JSON.parse(prs || "[]"); } catch {} + try { parsedIssues = JSON.parse(issues || "[]"); } catch {} + + return { + branch, + recentCommits: log.split("\n").filter(Boolean), + uncommitted: status.split("\n").filter(Boolean), + diffStat: diff, + openPrs: parsedPrs, + assignedIssues: parsedIssues, + gatheredAt: new Date().toISOString(), + }; +} + +// --- Persistence --- + +async function saveContext(workspacePath, data) { + if (!workspacePath) return; + const dir = join(workspacePath, "files"); + try { await mkdir(dir, { recursive: true }); } catch {} + await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2)); +} + +async function loadContext(workspacePath) { + if (!workspacePath) return null; + try { + const raw = await readFile(join(workspacePath, "files", "where-was-i-context.json"), "utf-8"); + return JSON.parse(raw); + } catch { return null; } +} + +// --- SSE --- + +function broadcast(instanceId, data) { + const clients = sseClients.get(instanceId); + if (!clients) return; + const payload = `data: ${JSON.stringify(data)}\n\n`; + for (const res of clients) { + try { res.write(payload); } catch {} + } +} + +// --- HTML renderer --- + +function renderHtml(instanceId) { + return ` + + + +Where Was I? + + + + + + +
+
+ + Reconstructing your context… +
+
+ + + +`; +} + +// --- Server --- + +async function startServer(instanceId, sessionRef, cwd, workspacePath) { + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + + if (url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + res.write(":\n\n"); + let clients = sseClients.get(instanceId); + if (!clients) { clients = new Set(); sseClients.set(instanceId, clients); } + clients.add(res); + req.on("close", () => { clients.delete(res); }); + return; + } + + if (url.pathname === "/context" && req.method === "GET") { + const data = contextCache.get(instanceId) || {}; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + return; + } + + if (url.pathname === "/refresh" && req.method === "POST") { + const data = await gatherContext(cwd); + contextCache.set(instanceId, data); + await saveContext(workspacePath, data); + broadcast(instanceId, data); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + return; + } + + if (url.pathname === "/resume" && req.method === "POST") { + let body = ""; + for await (const chunk of req) body += chunk; + let thread = null; + try { thread = JSON.parse(body).thread; } catch {} + + const ctx = contextCache.get(instanceId) || {}; + let prompt; + if (thread) { + prompt = `I was working on ${thread} and got interrupted. Here's my current context:\n\n` + + `**Branch:** ${ctx.branch || "unknown"}\n` + + `**Recent commits:** ${(ctx.recentCommits || []).join(", ")}\n` + + `**Uncommitted changes:** ${(ctx.uncommitted || []).join(", ")}\n` + + `**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ")}\n\n` + + `Help me pick up where I left off on this specific thread.`; + } else { + prompt = `I got interrupted and need to resume my work. Here's my full context:\n\n` + + `**Branch:** ${ctx.branch || "unknown"}\n` + + `**Recent commits:**\n${(ctx.recentCommits || []).map(c => "- " + c).join("\n")}\n\n` + + `**Uncommitted changes:**\n${(ctx.uncommitted || []).map(f => "- " + f).join("\n")}\n\n` + + `**Diff stat:**\n${ctx.diffStat || "none"}\n\n` + + `**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ") || "none"}\n` + + `**Assigned issues:** ${(ctx.assignedIssues || []).map(i => "#" + i.number + " " + i.title).join(", ") || "none"}\n\n` + + `Help me pick up where I left off. What should I focus on first?`; + } + + try { await sessionRef.send(prompt); } catch {} + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + // Default: serve HTML + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderHtml(instanceId)); + }); + + 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}/` }; +} + +// --- Extension --- + +let sessionRef = null; + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "where-was-i", + displayName: "Where Was I?", + description: "Interrupt Recovery — reconstructs your working context (branch, commits, changes, PRs) so you can resume after being pulled away.", + actions: [ + { + name: "refresh", + description: "Re-gather all git/project context and push updates to the canvas", + handler: async (ctx) => { + const data = await gatherContext(REPO_ROOT); + contextCache.set(ctx.instanceId, data); + if (sessionRef) await saveContext(sessionRef.workspacePath, data); + broadcast(ctx.instanceId, data); + return data; + }, + }, + { + name: "get_context", + description: "Return the currently assembled developer context as JSON", + handler: async (ctx) => { + return contextCache.get(ctx.instanceId) || {}; + }, + }, + { + name: "resume", + description: "Send a contextual 'resume' message to the agent with the developer's assembled state", + inputSchema: { + type: "object", + properties: { + thread: { + type: "string", + description: "Optional specific thread/topic to focus on when resuming", + }, + }, + }, + handler: async (ctx) => { + const thread = ctx.input?.thread || null; + const data = contextCache.get(ctx.instanceId) || {}; + let prompt; + if (thread) { + prompt = `I was working on ${thread} and got interrupted. Context: branch=${data.branch}, recent commits: ${(data.recentCommits || []).join("; ")}. Help me resume.`; + } else { + prompt = `Help me resume. Branch: ${data.branch}. Commits: ${(data.recentCommits || []).join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`; + } + if (sessionRef) await sessionRef.send(prompt); + return { sent: true }; + }, + }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, sessionRef, REPO_ROOT, sessionRef?.workspacePath); + servers.set(ctx.instanceId, entry); + } + + // Load persisted context or gather fresh + let data = await loadContext(sessionRef?.workspacePath); + if (!data) { + data = await gatherContext(REPO_ROOT); + await saveContext(sessionRef?.workspacePath, data); + } + contextCache.set(ctx.instanceId, data); + // Push to any waiting SSE clients + setTimeout(() => broadcast(ctx.instanceId, data), 100); + + return { title: "Where Was I?", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((r) => entry.server.close(() => r())); + } + sseClients.delete(ctx.instanceId); + contextCache.delete(ctx.instanceId); + }, + }), + ], +}); + +sessionRef = session; diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 87e8be45b..2b1b8d73b 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -57,6 +57,7 @@ export default defineConfig({ { label: "Skills", link: "/skills/" }, { label: "Hooks", link: "/hooks/" }, { label: "Workflows", link: "/workflows/" }, + { label: "Canvas Extensions", link: "/extensions/" }, { label: "Plugins", link: "/plugins/" }, { label: "Tools", link: "/tools/" }, { label: "Contributors", link: "/contributors/" }, diff --git a/website/src/pages/extensions.astro b/website/src/pages/extensions.astro new file mode 100644 index 000000000..98e98a26b --- /dev/null +++ b/website/src/pages/extensions.astro @@ -0,0 +1,57 @@ +--- +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import extensionsData from '../../public/data/extensions.json'; +import ContributeCTA from '../components/ContributeCTA.astro'; +import EmbeddedPageData from '../components/EmbeddedPageData.astro'; +import PageHeader from '../components/PageHeader.astro'; +import BackToTop from '../components/BackToTop.astro'; +import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render'; + +const initialItems = sortExtensions(extensionsData.items, 'title'); +--- + + +
+ + +
+
+
+
+
{initialItems.length} extensions
+
+ Sort +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + +
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index f2ffae9af..5420b2ba4 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -170,11 +170,32 @@ const base = import.meta.env.BASE_URL; - + + +
+

Canvas Extensions

+

Interactive canvas extensions for Copilot app experiences

+
+
+ - +
+