import { createServer } from "node:http"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const servers = new Map(); const extensionDir = fileURLToPath(new URL(".", import.meta.url)); const artifactsDir = path.join(extensionDir, "artifacts"); const stateFile = path.join(artifactsDir, "backlog-triage-state.json"); const decisions = ["assign_agent", "needs_info", "not_now", "close", "ignore"]; const execFileAsync = promisify(execFile); const MAX_SYNC_ISSUES = 200; const defaultFilters = { timeWindow: "any", labels: [], assignees: [], query: "", sortBy: "updated-desc", }; const filterSchema = { type: "object", properties: { timeWindow: { type: "string", enum: ["any", "1d", "3d", "7d", "14d", "30d", "90d"] }, labels: { type: "array", items: { type: "string" } }, assignees: { type: "array", items: { type: "string" } }, query: { type: "string" }, sortBy: { type: "string", enum: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"] }, }, additionalProperties: false, }; let activeSession = null; const MAX_REQUEST_BODY_BYTES = 1024 * 1024; let storage = { boards: {} }; let storageLoaded = false; let persistStorageQueue = Promise.resolve(); async function ensureStorageLoaded() { if (storageLoaded) { return; } await fs.mkdir(artifactsDir, { recursive: true }); try { const raw = await fs.readFile(stateFile, "utf8"); storage = JSON.parse(raw); } catch (error) { if (error && error.code !== "ENOENT") { throw error; } storage = { boards: {} }; } storageLoaded = true; } async function persistStorage() { await fs.mkdir(artifactsDir, { recursive: true }); const snapshot = JSON.stringify(storage, null, 2); persistStorageQueue = persistStorageQueue .catch(() => undefined) .then(async () => { const tempStateFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`; await fs.writeFile(tempStateFile, snapshot, "utf8"); await fs.rename(tempStateFile, stateFile); }); await persistStorageQueue; } function normalizeText(value, fallback = "") { return typeof value === "string" ? value.trim() : fallback; } function escapeHtml(value) { return normalizeText(value).replace(/[&<>"']/g, (char) => { if (char === "&") return "&"; if (char === "<") return "<"; if (char === ">") return ">"; if (char === '"') return """; return "'"; }); } function normalizeStringArray(values) { if (!Array.isArray(values)) { return []; } return values.map((value) => normalizeText(value)).filter(Boolean); } function normalizeFilters(raw, fallback = defaultFilters) { const merged = raw && typeof raw === "object" ? { ...fallback, ...raw } : { ...fallback }; const legacyAssignee = normalizeText(merged.assignee); return { timeWindow: ["any", "1d", "3d", "7d", "14d", "30d", "90d"].includes(merged.timeWindow) ? merged.timeWindow : "any", labels: normalizeStringArray(merged.labels), assignees: legacyAssignee ? [legacyAssignee] : normalizeStringArray(merged.assignees), query: normalizeText(merged.query).toLowerCase(), sortBy: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"].includes(merged.sortBy) ? merged.sortBy : "updated-desc", }; } function parseDateToMs(value) { const timestamp = Date.parse(value || ""); return Number.isFinite(timestamp) ? timestamp : 0; } function getTimeWindowMs(timeWindow) { if (timeWindow === "1d") return 1 * 24 * 60 * 60 * 1000; if (timeWindow === "3d") return 3 * 24 * 60 * 60 * 1000; if (timeWindow === "7d") return 7 * 24 * 60 * 60 * 1000; if (timeWindow === "14d") return 14 * 24 * 60 * 60 * 1000; if (timeWindow === "30d") return 30 * 24 * 60 * 60 * 1000; if (timeWindow === "90d") return 90 * 24 * 60 * 60 * 1000; return 0; } function getIssueLabels(issue) { return Array.isArray(issue?.labels) ? issue.labels.map((label) => normalizeText(label?.name).toLowerCase()).filter(Boolean) : []; } function getIssueAssignees(issue) { return Array.isArray(issue?.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login).toLowerCase()).filter(Boolean) : []; } function issueMatchesFilters(issue, filters) { const now = Date.now(); const cutoffWindow = getTimeWindowMs(filters.timeWindow); if (cutoffWindow > 0) { const updatedAtMs = parseDateToMs(issue.updatedAt); if (!updatedAtMs || now - updatedAtMs > cutoffWindow) { return false; } } const issueLabels = getIssueLabels(issue); const requiredLabels = filters.labels.map((label) => label.toLowerCase()); if (requiredLabels.length > 0) { if (!requiredLabels.some((label) => issueLabels.includes(label))) { return false; } } const assigneeFilters = normalizeStringArray(filters.assignees).map((assignee) => assignee.toLowerCase()); if (assigneeFilters.length > 0) { const assignees = getIssueAssignees(issue); const isUnassignedMatch = assigneeFilters.includes("unassigned") && assignees.length === 0; const hasNamedMatch = assigneeFilters.some((wanted) => wanted !== "unassigned" && assignees.includes(wanted)); if (!isUnassignedMatch && !hasNamedMatch) { return false; } } if (filters.query) { const haystack = `${normalizeText(issue.title)} ${normalizeText(issue.body || "")}`.toLowerCase(); if (!haystack.includes(filters.query)) { return false; } } return true; } function sortIssues(issues, sortBy) { const sorted = [...issues]; if (sortBy === "random") { for (let i = sorted.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [sorted[i], sorted[j]] = [sorted[j], sorted[i]]; } return sorted; } sorted.sort((left, right) => { if (sortBy === "created-asc") { return parseDateToMs(left.createdAt) - parseDateToMs(right.createdAt); } if (sortBy === "created-desc") { return parseDateToMs(right.createdAt) - parseDateToMs(left.createdAt); } if (sortBy === "updated-asc") { return parseDateToMs(left.updatedAt) - parseDateToMs(right.updatedAt); } if (sortBy === "title-asc") { return normalizeText(left.title).localeCompare(normalizeText(right.title)); } return parseDateToMs(right.updatedAt) - parseDateToMs(left.updatedAt); }); return sorted; } function normalizeItem(raw, index) { const idFromInput = normalizeText(raw?.id); const title = normalizeText(raw?.title, `Item ${index + 1}`); const id = idFromInput || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || `item-${index + 1}`; return { id, title, description: normalizeText(raw?.description), details: normalizeText(raw?.details), repo: normalizeText(raw?.repo), number: normalizeText(raw?.number), url: normalizeText(raw?.url), labels: normalizeStringArray(raw?.labels), assignees: normalizeStringArray(raw?.assignees), createdAt: normalizeText(raw?.createdAt), updatedAt: normalizeText(raw?.updatedAt), author: normalizeText(raw?.author), }; } function getOrCreateBoard(boardId) { if (!storage.boards[boardId]) { storage.boards[boardId] = { id: boardId, title: "Backlog Triage", items: [], decisions: {}, workStatus: {}, filters: { ...defaultFilters }, updatedAt: new Date().toISOString(), }; } if (!storage.boards[boardId].workStatus || typeof storage.boards[boardId].workStatus !== "object") { storage.boards[boardId].workStatus = {}; } return storage.boards[boardId]; } function setBoardItems(board, items, replace = true) { const normalized = Array.isArray(items) ? items.map((item, index) => normalizeItem(item, index)) : []; const repoFromItems = normalized.find((item) => normalizeText(item.repo)); if (repoFromItems) { board.repo = repoFromItems.repo; } if (replace) { board.items = normalized; } else { const existingById = new Map(board.items.map((item) => [item.id, item])); for (const item of normalized) { existingById.set(item.id, item); } board.items = [...existingById.values()]; } board.updatedAt = new Date().toISOString(); } function applyBoardDecision(board, itemId, decision, extra = {}) { if (!decisions.includes(decision)) { throw new Error(`Unsupported decision "${decision}"`); } const item = board.items.find((candidate) => candidate.id === itemId); if (!item) { throw new Error(`Item "${itemId}" not found on board "${board.id}"`); } board.decisions[itemId] = { decision, agent: normalizeText(extra.agent), note: normalizeText(extra.note), at: new Date().toISOString(), }; board.updatedAt = new Date().toISOString(); } function resetBoardDecisions(board) { board.decisions = {}; board.updatedAt = new Date().toISOString(); } function buildItemWorkStatus(board, item) { const statuses = []; const assignees = normalizeStringArray(item?.assignees); if (assignees.length > 0) { statuses.push({ label: `Assigned: ${assignees.join(", ")}` }); } const decision = board.decisions?.[item.id]; const triageAgent = normalizeText(decision?.decision === "assign_agent" ? decision?.agent : ""); if (assignees.length === 0 && triageAgent) { statuses.push({ label: `Assigned in triage: ${triageAgent}` }); } const work = board.workStatus?.[item.id]; if (work?.sessionState === "active") { const sessionName = normalizeText(work.sessionName); statuses.push({ label: sessionName ? `Session active: ${sessionName}` : "Session active" }); } else if (work?.sessionState === "starting") { statuses.push({ label: "Session starting" }); } else if (work?.sessionState === "requested") { const sessionName = normalizeText(work.sessionName); statuses.push({ label: sessionName ? `Session requested: ${sessionName}` : "Session requested" }); } return statuses; } function buildBoardState(board) { const allLabels = [...new Set(board.items.flatMap((item) => (Array.isArray(item.labels) ? item.labels : [])))].sort((a, b) => a.localeCompare(b), ); const hasUnassigned = board.items.some((item) => !Array.isArray(item.assignees) || item.assignees.length === 0); const allAssignees = [ ...new Set(board.items.flatMap((item) => (Array.isArray(item.assignees) ? item.assignees : []))), ].sort((a, b) => a.localeCompare(b)); if (hasUnassigned) { allAssignees.unshift("unassigned"); } const pending = []; const resolved = []; for (const item of board.items) { const itemWithStatus = { ...item, workStatus: buildItemWorkStatus(board, item) }; const result = board.decisions[item.id]; if (result) { resolved.push({ ...itemWithStatus, result }); } else { pending.push(itemWithStatus); } } return { boardId: board.id, title: board.title, repo: normalizeText(board.repo), syncedAt: normalizeText(board.syncedAt), filters: normalizeFilters(board.filters, defaultFilters), availableLabels: allLabels, availableAssignees: allAssignees, pending, resolved, decisionCounts: resolved.reduce((counts, item) => { const key = item.result.decision; counts[key] = (counts[key] || 0) + 1; return counts; }, {}), updatedAt: board.updatedAt, }; } function buildIssueDetails(issue) { const parts = []; const author = normalizeText(issue.author?.login); if (author) { parts.push(`Author: ${author}`); } if (normalizeText(issue.createdAt)) { parts.push(`Created: ${normalizeText(issue.createdAt).slice(0, 10)}`); } if (normalizeText(issue.updatedAt)) { parts.push(`Updated: ${normalizeText(issue.updatedAt).slice(0, 10)}`); } return parts.join(" | "); } function buildIssueDescription(issue) { const body = normalizeText(issue.body); if (!body) { return ""; } const normalized = body .replace(/\r/g, "") .replace(/!\[.*?\]\(.*?\)/g, "") .replace(/\n{2,}/g, "\n\n") .trim(); if (normalized.length <= 2200) { return normalized; } return `${normalized.slice(0, 2197).trimEnd()}...`; } async function runGhJson(args, cwd) { const result = await execFileAsync("gh", args, { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024, }); return JSON.parse(result.stdout); } async function runGh(args, cwd) { const result = await execFileAsync("gh", args, { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024, }); return result.stdout; } async function closeGithubIssue(board, item, note) { const issueNumber = normalizeText(item?.number); const repo = normalizeText(board?.repo || item?.repo); if (!issueNumber || !repo) { throw new Error("Cannot close issue on GitHub because repo or issue number is missing."); } const args = ["issue", "close", issueNumber, "--repo", repo]; const comment = normalizeText(note); if (comment) { args.push("--comment", comment); } try { await runGh(args, activeSession?.workspacePath || process.cwd()); } catch (error) { const stderr = normalizeText(error?.stderr || ""); if (stderr.toLowerCase().includes("already closed")) { return; } throw new Error(stderr || `Failed to close issue #${issueNumber} in ${repo}.`); } } async function commentGithubIssue(board, item, note) { const repo = normalizeText(board?.repo || item?.repo); const issueNumber = extractIssueNumber(item); const comment = normalizeText(note); if (!repo || !issueNumber) { throw new Error("Cannot comment on issue because repo or issue number is missing."); } if (!comment) { return; } try { await runGh(["issue", "comment", issueNumber, "--repo", repo, "--body", comment], activeSession?.workspacePath || process.cwd()); } catch (error) { const stderr = normalizeText(error?.stderr || ""); throw new Error(stderr || `Failed to comment on issue #${issueNumber} in ${repo}.`); } } function extractIssueNumber(item) { const explicit = normalizeText(item?.number); if (/^\d+$/.test(explicit)) { return explicit; } const idMatch = normalizeText(item?.id).match(/^issue-(\d+)$/i); if (idMatch) { return idMatch[1]; } const titleMatch = normalizeText(item?.title).match(/^#(\d+)\b/); if (titleMatch) { return titleMatch[1]; } return ""; } async function startImplementationSession(board, item, agent, note) { if (!activeSession) { throw new Error("Copilot session is unavailable for starting implementation sessions."); } const repo = normalizeText(board?.repo || item?.repo); const issueNumber = extractIssueNumber(item); if (!repo || !issueNumber) { throw new Error("Cannot start implementation session because repo or issue number is missing."); } const rawTitle = normalizeText(item?.title); const issueTitle = rawTitle.replace(new RegExp(`^#${issueNumber}\\s*`), "").trim() || rawTitle || `Issue #${issueNumber}`; const summary = normalizeText(item?.description); const kickoffLines = [ `Implement GitHub issue #${issueNumber}: ${issueTitle}`, `Repository: ${repo}`, ]; if (summary) { kickoffLines.push(`Context: ${summary}`); } if (normalizeText(note)) { kickoffLines.push(`Triage note: ${normalizeText(note)}`); } kickoffLines.push( "Deliver a complete fix with code changes, run relevant validation, and open a PR-ready branch state with a concise summary.", ); const kickoffPrompt = kickoffLines.join("\n"); const sessionRequest = [ `Create a new implementation project session for GitHub issue #${issueNumber} in ${repo}.`, "Use the open_issue_session tool with these exact fields:", `- repo_full_name: ${JSON.stringify(repo)}`, `- issue_number: ${Number(issueNumber)}`, `- issue_title: ${JSON.stringify(issueTitle)}`, '- kickoff_mode: "autopilot"', '- coordinate_with_creator: true', '- notify_on_idle: "once"', `- kickoff_prompt: ${JSON.stringify(kickoffPrompt)}`, "", "After the tool call succeeds, reply with a one-line confirmation including the new session name.", ].join("\n"); await activeSession.send({ prompt: sessionRequest, mode: "immediate", displayPrompt: `Start implementation session for #${issueNumber}`, }); return { sessionState: "requested", sessionName: `Issue #${issueNumber}`, issueNumber, agent: normalizeText(agent), requestedAt: new Date().toISOString(), }; } function pruneDecisionsForCurrentItems(board) { const currentIds = new Set(board.items.map((item) => item.id)); for (const itemId of Object.keys(board.decisions)) { if (!currentIds.has(itemId)) { delete board.decisions[itemId]; } } if (board.workStatus && typeof board.workStatus === "object") { for (const itemId of Object.keys(board.workStatus)) { if (!currentIds.has(itemId)) { delete board.workStatus[itemId]; } } } } async function syncBoardFromRepo(board, filtersInput) { const workspacePath = activeSession?.workspacePath; let repo = normalizeText(board.repo); if (!repo && workspacePath) { const repoData = await runGhJson(["repo", "view", "--json", "nameWithOwner"], workspacePath); repo = normalizeText(repoData?.nameWithOwner); } if (!repo) { throw new Error("Repository is not configured. Open the canvas with a repo or call sync_from_repo with { repo: \"owner/name\" }."); } const filters = normalizeFilters(filtersInput, board.filters || defaultFilters); const issues = await runGhJson( [ "issue", "list", "--repo", repo, "--state", "open", "--limit", String(MAX_SYNC_ISSUES), "--json", "number,title,url,labels,assignees,createdAt,updatedAt,author,body", ], workspacePath || process.cwd(), ); const filteredIssues = Array.isArray(issues) ? sortIssues(issues.filter((issue) => issueMatchesFilters(issue, filters)), filters.sortBy) : []; const items = filteredIssues.map((issue) => ({ id: `issue-${issue.number}`, title: `#${issue.number} ${normalizeText(issue.title, "Untitled issue")}`, description: buildIssueDescription(issue), details: buildIssueDetails(issue), repo, number: String(issue.number), url: normalizeText(issue.url), labels: Array.isArray(issue.labels) ? issue.labels.map((label) => normalizeText(label?.name)).filter(Boolean) : [], assignees: Array.isArray(issue.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login)).filter(Boolean) : [], createdAt: normalizeText(issue.createdAt), updatedAt: normalizeText(issue.updatedAt), author: normalizeText(issue.author?.login), })); setBoardItems(board, items, true); pruneDecisionsForCurrentItems(board); board.source = "repo"; board.repo = repo; board.filters = filters; board.syncedAt = new Date().toISOString(); } function renderHtml(instanceId, title) { const safeTitle = escapeHtml(title || "Backlog Swipe Triage"); const safeInstanceId = escapeHtml(instanceId || "default"); return ` ${safeTitle}

${safeTitle}

Instance: ${safeInstanceId} Loading board…
All labels
All assignees

Issue
Done
Applying action…
Swipe-up quick responses
Decision summary
Swipe mappings: left=close, right=assign agent, up=more options, down=ignore. Arrow keys work too.
`; } function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) { return new Promise((resolve, reject) => { const chunks = []; let totalBytes = 0; let settled = false; req.on("data", (chunk) => { if (settled) { return; } totalBytes += chunk.length; if (totalBytes > maxBytes) { settled = true; const error = new Error(`Request body exceeds ${maxBytes} bytes.`); error.statusCode = 413; req.destroy(error); reject(error); return; } chunks.push(chunk); }); req.on("end", () => { if (settled) { return; } const raw = Buffer.concat(chunks).toString("utf8"); if (!raw) { resolve({}); return; } try { resolve(JSON.parse(raw)); } catch (error) { error.statusCode = 400; reject(error); } }); req.on("error", (error) => { if (settled) { return; } settled = true; reject(error); }); }); } async function handleServerRequest(instanceId, req, res) { const entry = servers.get(instanceId); if (!entry) { res.statusCode = 404; res.end("Instance not found"); return; } await ensureStorageLoaded(); const board = getOrCreateBoard(entry.boardId); board.title = entry.title; if (req.method === "GET" && req.url === "/") { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(renderHtml(instanceId, board.title)); return; } if (req.method === "GET" && req.url === "/state") { res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(buildBoardState(board))); return; } if (req.method === "POST" && req.url === "/sync") { try { const payload = await readJson(req); const repo = normalizeText(payload?.repo); if (repo) { board.repo = repo; } if (payload?.resetDecisions === true) { resetBoardDecisions(board); } await syncBoardFromRepo(board, payload?.filters); await persistStorage(); res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(buildBoardState(board))); } catch (error) { res.statusCode = error?.statusCode || 500; res.end(error instanceof Error ? error.message : "Failed to sync from repo"); } return; } if (req.method === "POST" && req.url === "/decision") { let payload; try { payload = await readJson(req); } catch (error) { res.statusCode = error?.statusCode || 400; res.end( error?.statusCode === 413 ? "Request body too large" : "Invalid JSON payload", ); return; } const itemId = normalizeText(payload?.itemId); const decision = normalizeText(payload?.decision); const item = board.items.find((candidate) => candidate.id === itemId); if (!itemId || !decision) { res.statusCode = 400; res.end("itemId and decision are required"); return; } if (!item) { res.statusCode = 404; res.end(`Item "${itemId}" not found`); return; } if (decision === "close") { await closeGithubIssue(board, item, payload?.note); } if (payload?.quickResponse === true && decision !== "close" && normalizeText(payload?.note)) { await commentGithubIssue(board, item, payload?.note); } if (decision === "assign_agent") { const sessionStatus = await startImplementationSession(board, item, payload?.agent, payload?.note); board.workStatus[itemId] = { ...sessionStatus, agent: normalizeText(payload?.agent), }; } applyBoardDecision(board, itemId, decision, { agent: payload?.agent, note: payload?.note, }); await persistStorage(); res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(buildBoardState(board))); return; } res.statusCode = 404; res.end("Not found"); } async function startServer(instanceId) { const server = createServer((req, res) => { handleServerRequest(instanceId, req, res).catch((error) => { if (res.headersSent) { res.end(); return; } res.statusCode = error?.statusCode || 500; res.end(error instanceof Error ? error.message : "Internal server error"); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; return { server, url: `http://127.0.0.1:${port}/` }; } const session = await joinSession({ canvases: [ createCanvas({ id: "backlog-swipe-triage", displayName: "Backlog Swipe Triage", description: "Tinder-style backlog triage with swipe directions for assign, needs info, not now, close, and ignore.", inputSchema: { type: "object", properties: { boardId: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 }, syncFromRepo: { type: "boolean" }, repo: { type: "string", minLength: 1 }, filters: filterSchema, items: { type: "array", items: { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, details: { type: "string" }, repo: { type: "string" }, number: { type: "string" }, url: { type: "string" }, }, required: ["title"], additionalProperties: true, }, }, }, additionalProperties: false, }, actions: [ { name: "sync_from_repo", description: "Load open issues from the current repository into the triage board.", inputSchema: { type: "object", properties: { boardId: { type: "string", minLength: 1 }, title: { type: "string" }, repo: { type: "string", minLength: 1 }, filters: filterSchema, }, required: ["boardId"], additionalProperties: false, }, handler: async (ctx) => { await ensureStorageLoaded(); const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); const title = normalizeText(ctx.input?.title); if (title) { board.title = title; } const repo = normalizeText(ctx.input?.repo); if (repo) { board.repo = repo; } await syncBoardFromRepo(board, ctx.input?.filters); await persistStorage(); return buildBoardState(board); }, }, { name: "seed_backlog", description: "Seed or update backlog items for a triage board.", inputSchema: { type: "object", properties: { boardId: { type: "string", minLength: 1 }, title: { type: "string" }, replace: { type: "boolean" }, items: { type: "array", items: { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, details: { type: "string" }, repo: { type: "string" }, number: { type: "string" }, url: { type: "string" }, }, required: ["title"], additionalProperties: true, }, }, }, required: ["boardId", "items"], additionalProperties: false, }, handler: async (ctx) => { await ensureStorageLoaded(); const boardId = normalizeText(ctx.input?.boardId, "default"); const board = getOrCreateBoard(boardId); const title = normalizeText(ctx.input?.title); if (title) { board.title = title; } setBoardItems(board, ctx.input?.items, ctx.input?.replace !== false); await persistStorage(); return buildBoardState(board); }, }, { name: "apply_decision", description: "Apply a triage decision to a backlog item.", inputSchema: { type: "object", properties: { boardId: { type: "string", minLength: 1 }, itemId: { type: "string", minLength: 1 }, decision: { type: "string", enum: decisions }, agent: { type: "string" }, note: { type: "string" }, commentOnIssue: { type: "boolean" }, }, required: ["boardId", "itemId", "decision"], additionalProperties: false, }, handler: async (ctx) => { await ensureStorageLoaded(); const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); const itemId = normalizeText(ctx.input?.itemId); const item = board.items.find((candidate) => candidate.id === itemId); const decision = normalizeText(ctx.input?.decision); if (!item) { throw new Error(`Item "${itemId}" not found`); } if (decision === "close") { await closeGithubIssue(board, item, ctx.input?.note); } if (ctx.input?.commentOnIssue === true && decision !== "close" && normalizeText(ctx.input?.note)) { await commentGithubIssue(board, item, ctx.input?.note); } if (decision === "assign_agent") { const sessionStatus = await startImplementationSession(board, item, ctx.input?.agent, ctx.input?.note); board.workStatus[itemId] = { ...sessionStatus, agent: normalizeText(ctx.input?.agent), }; } applyBoardDecision(board, itemId, decision, { agent: ctx.input?.agent, note: ctx.input?.note, }); await persistStorage(); return buildBoardState(board); }, }, { name: "get_board", description: "Get pending and triaged items for a triage board.", inputSchema: { type: "object", properties: { boardId: { type: "string", minLength: 1 }, }, required: ["boardId"], additionalProperties: false, }, handler: async (ctx) => { await ensureStorageLoaded(); const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); return buildBoardState(board); }, }, ], open: async (ctx) => { await ensureStorageLoaded(); const boardId = normalizeText(ctx.input?.boardId, "default"); const board = getOrCreateBoard(boardId); const title = normalizeText(ctx.input?.title, board.title || "Backlog Triage"); board.title = title; const repo = normalizeText(ctx.input?.repo); if (repo) { board.repo = repo; } if (ctx.input?.filters && typeof ctx.input.filters === "object") { board.filters = normalizeFilters(ctx.input.filters, board.filters || defaultFilters); } else if (!board.filters) { board.filters = { ...defaultFilters }; } const syncFromRepo = ctx.input?.syncFromRepo !== false; if (Array.isArray(ctx.input?.items) && ctx.input.items.length > 0) { setBoardItems(board, ctx.input.items, true); await persistStorage(); } else if (syncFromRepo) { await syncBoardFromRepo(board, board.filters); await persistStorage(); } let entry = servers.get(ctx.instanceId); if (!entry) { entry = await startServer(ctx.instanceId); servers.set(ctx.instanceId, entry); } entry.boardId = boardId; entry.title = title; return { title, status: "Swipe to triage backlog", url: entry.url, }; }, onClose: async (ctx) => { const entry = servers.get(ctx.instanceId); if (entry) { servers.delete(ctx.instanceId); await new Promise((resolve) => entry.server.close(() => resolve())); } }, }), ], }); activeSession = session;