// 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;