Last active
June 8, 2026 19:02
-
-
Save jongalloway/af4e7397d9f3703aa1339b43aeb7ebe0 to your computer and use it in GitHub Desktop.
GitHub Copilot App canvas - squad-status. You can install using the GitHub App command palette's "Install extension from gist..." option.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "squad-status", | |
| "version": 1 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createServer } from "node:http"; | |
| import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { execFile } from "node:child_process"; | |
| import { promisify } from "node:util"; | |
| const execFileAsync = promisify(execFile); | |
| const servers = new Map(); | |
| const snapshots = new Map(); | |
| const subscribers = new Map(); | |
| const instanceRoots = new Map(); | |
| const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); | |
| const REPO_HINT = path.resolve(EXTENSION_DIR, "..", "..", ".."); | |
| function parseJson(stdout, fallback) { | |
| try { | |
| return JSON.parse(stdout); | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| async function readFileIfExists(filePath) { | |
| try { | |
| return await fs.readFile(filePath, "utf8"); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| async function countDirectoryEntries(dirPath) { | |
| try { | |
| const entries = await fs.readdir(dirPath, { withFileTypes: true }); | |
| return entries.filter((entry) => entry.isFile()).length; | |
| } catch { | |
| return 0; | |
| } | |
| } | |
| async function pathExists(targetPath) { | |
| try { | |
| await fs.access(targetPath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function parentDirs(startPath) { | |
| const dirs = []; | |
| let current = path.resolve(startPath); | |
| while (true) { | |
| dirs.push(current); | |
| const parent = path.dirname(current); | |
| if (parent === current) break; | |
| current = parent; | |
| } | |
| return dirs; | |
| } | |
| async function resolveWorkspaceRoot(preferredPath) { | |
| const candidates = []; | |
| for (const maybePath of [preferredPath, process.env.COPILOT_WORKSPACE_PATH, session?.workspacePath, REPO_HINT, EXTENSION_DIR, process.cwd()]) { | |
| if (typeof maybePath === "string" && maybePath.trim()) { | |
| candidates.push(path.resolve(maybePath.trim())); | |
| } | |
| } | |
| const dirs = []; | |
| const seen = new Set(); | |
| for (const candidate of candidates) { | |
| for (const parent of parentDirs(candidate)) { | |
| if (seen.has(parent)) continue; | |
| seen.add(parent); | |
| dirs.push(parent); | |
| } | |
| } | |
| for (const dir of dirs) { | |
| if (await pathExists(path.join(dir, ".squad", "team.md"))) return dir; | |
| } | |
| for (const dir of dirs) { | |
| if (await pathExists(path.join(dir, ".git"))) return dir; | |
| } | |
| try { | |
| const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { | |
| cwd: candidates[0] || process.cwd(), | |
| windowsHide: true, | |
| }); | |
| const root = stdout.trim(); | |
| if (root) return root; | |
| } catch { | |
| // fall through to conservative fallback | |
| } | |
| return candidates[0] || process.cwd(); | |
| } | |
| async function resolvePanelWorkspaceRoot(instanceId, preferredPath) { | |
| const root = await resolveWorkspaceRoot(preferredPath); | |
| if (await pathExists(path.join(root, ".squad", "team.md"))) return root; | |
| const currentRoot = instanceRoots.get(instanceId); | |
| if (currentRoot && await pathExists(path.join(currentRoot, ".squad", "team.md"))) return currentRoot; | |
| for (const knownRoot of instanceRoots.values()) { | |
| if (knownRoot && await pathExists(path.join(knownRoot, ".squad", "team.md"))) return knownRoot; | |
| } | |
| return root; | |
| } | |
| function extractMemberRows(teamMd) { | |
| const lines = teamMd.split(/\r?\n/); | |
| const start = lines.findIndex((line) => line.trim() === "## Members"); | |
| if (start < 0) return []; | |
| const section = []; | |
| for (let i = start + 1; i < lines.length; i += 1) { | |
| const line = lines[i]; | |
| if (line.startsWith("## ")) break; | |
| section.push(line); | |
| } | |
| const rows = section.filter((line) => /^\|\s*[^|]+\s*\|/.test(line.trim())); | |
| const members = []; | |
| for (const row of rows) { | |
| const cols = row.split("|").map((c) => c.trim()); | |
| if (cols.length < 5) continue; | |
| const name = cols[1]; | |
| const role = cols[2]; | |
| const status = cols[4]; | |
| if (!name || name === "Name" || name === "------") continue; | |
| members.push({ name, role, status }); | |
| } | |
| return members; | |
| } | |
| async function readSquadStatus(workspacePath) { | |
| const squadRoot = path.join(workspacePath, ".squad"); | |
| const [teamMd, decisionsMd, nowMd, orchestrationLogCount, sessionLogCount, inboxCount] = await Promise.all([ | |
| readFileIfExists(path.join(squadRoot, "team.md")), | |
| readFileIfExists(path.join(squadRoot, "decisions.md")), | |
| readFileIfExists(path.join(squadRoot, "identity", "now.md")), | |
| countDirectoryEntries(path.join(squadRoot, "orchestration-log")), | |
| countDirectoryEntries(path.join(squadRoot, "log")), | |
| countDirectoryEntries(path.join(squadRoot, "decisions", "inbox")), | |
| ]); | |
| const members = extractMemberRows(teamMd); | |
| const decisionCount = (decisionsMd.match(/^###\s+D\d+:/gm) || []).length; | |
| const focusMatch = nowMd.match(/^focus_area:\s*(.+)$/m); | |
| const decisions = [...decisionsMd.matchAll(/^###\s+(D\d+):\s*(.+)$/gm)] | |
| .map((match) => ({ id: match[1], title: match[2] })) | |
| .slice(0, 20); | |
| return { | |
| members, | |
| decisionCount, | |
| focusArea: focusMatch ? focusMatch[1].trim() : "Not set", | |
| decisions, | |
| orchestrationLogCount, | |
| sessionLogCount, | |
| inboxCount, | |
| }; | |
| } | |
| function parseRepoFromRemoteUrl(url) { | |
| const trimmed = url.trim(); | |
| const httpsMatch = trimmed.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/i); | |
| if (!httpsMatch) return null; | |
| return { owner: httpsMatch[1], repo: httpsMatch[2] }; | |
| } | |
| async function discoverRepo(workspacePath) { | |
| try { | |
| const { stdout } = await execFileAsync("git", ["-C", workspacePath, "remote", "get-url", "origin"], { windowsHide: true }); | |
| return parseRepoFromRemoteUrl(stdout); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function readGitHubViaPublicApi(workspacePath) { | |
| const repo = await discoverRepo(workspacePath); | |
| if (!repo) return null; | |
| const endpoint = `https://api.github.com/repos/${repo.owner}/${repo.repo}/issues?state=open&per_page=100`; | |
| const res = await fetch(endpoint, { headers: { "User-Agent": "copilot-squad-status-canvas" } }); | |
| if (!res.ok) return null; | |
| const all = parseJson(await res.text(), []); | |
| const issues = all.filter((item) => !item.pull_request); | |
| const prs = all.filter((item) => item.pull_request); | |
| const squadIssues = issues.filter((issue) => | |
| (issue.labels || []).some((label) => label.name === "squad") | |
| ); | |
| return { | |
| allOpenIssues: issues.length, | |
| squadOpenIssues: squadIssues.length, | |
| openPrs: prs.length, | |
| draftPrs: prs.filter((pr) => pr.draft).length, | |
| reviewChangesRequested: 0, | |
| topIssues: squadIssues.slice(0, 10).map((issue) => ({ | |
| number: issue.number, | |
| title: issue.title, | |
| labels: issue.labels || [], | |
| url: issue.html_url || "", | |
| updatedAt: issue.updated_at || "", | |
| })), | |
| dataSource: "github-rest", | |
| }; | |
| } | |
| async function readGitHubWork(workspacePath) { | |
| try { | |
| const repo = await discoverRepo(workspacePath); | |
| if (!repo) throw new Error("Unable to resolve repository for gh CLI"); | |
| const repoRef = `${repo.owner}/${repo.repo}`; | |
| const [squadIssuesRaw, allIssuesRaw, prsRaw] = await Promise.all([ | |
| execFileAsync("gh", ["issue", "list", "-R", repoRef, "--label", "squad", "--state", "open", "--json", "number,title,labels,url,updatedAt"], { | |
| windowsHide: true, | |
| cwd: workspacePath, | |
| }), | |
| execFileAsync("gh", ["issue", "list", "-R", repoRef, "--state", "open", "--json", "number,title,labels"], { | |
| windowsHide: true, | |
| cwd: workspacePath, | |
| }), | |
| execFileAsync("gh", ["pr", "list", "-R", repoRef, "--state", "open", "--json", "number,title,isDraft,reviewDecision"], { | |
| windowsHide: true, | |
| cwd: workspacePath, | |
| }), | |
| ]); | |
| const squadIssues = parseJson(squadIssuesRaw.stdout, []); | |
| const allIssues = parseJson(allIssuesRaw.stdout, []); | |
| const prs = parseJson(prsRaw.stdout, []); | |
| return { | |
| allOpenIssues: allIssues.length, | |
| squadOpenIssues: squadIssues.length, | |
| openPrs: prs.length, | |
| draftPrs: prs.filter((pr) => pr.isDraft).length, | |
| reviewChangesRequested: prs.filter((pr) => pr.reviewDecision === "CHANGES_REQUESTED").length, | |
| topIssues: squadIssues.slice(0, 10).map((issue) => ({ | |
| number: issue.number, | |
| title: issue.title, | |
| labels: issue.labels || [], | |
| url: issue.url || "", | |
| updatedAt: issue.updatedAt || "", | |
| })), | |
| dataSource: "gh-cli", | |
| }; | |
| } catch { | |
| const fallback = await readGitHubViaPublicApi(workspacePath); | |
| if (fallback) return fallback; | |
| return { | |
| allOpenIssues: 0, | |
| squadOpenIssues: 0, | |
| openPrs: 0, | |
| draftPrs: 0, | |
| reviewChangesRequested: 0, | |
| topIssues: [], | |
| ghUnavailable: true, | |
| dataSource: "none", | |
| }; | |
| } | |
| } | |
| function buildRecentDayKeys(days = 14) { | |
| const keys = []; | |
| const now = new Date(); | |
| for (let i = days - 1; i >= 0; i -= 1) { | |
| const d = new Date(now); | |
| d.setHours(0, 0, 0, 0); | |
| d.setDate(d.getDate() - i); | |
| keys.push(d.toISOString().slice(0, 10)); | |
| } | |
| return keys; | |
| } | |
| async function readIssueUpdatesViaPublicApi(workspacePath) { | |
| const repo = await discoverRepo(workspacePath); | |
| if (!repo) return []; | |
| const endpoint = `https://api.github.com/repos/${repo.owner}/${repo.repo}/issues?state=all&labels=squad&per_page=100`; | |
| const res = await fetch(endpoint, { headers: { "User-Agent": "copilot-squad-status-canvas" } }); | |
| if (!res.ok) return []; | |
| const all = parseJson(await res.text(), []); | |
| return all | |
| .filter((item) => !item.pull_request && item.updated_at) | |
| .map((item) => item.updated_at); | |
| } | |
| async function readActivityHistory(workspacePath) { | |
| const dayKeys = buildRecentDayKeys(14); | |
| const points = dayKeys.map((date) => ({ date, label: date.slice(5), issues: 0, commits: 0 })); | |
| const byDate = new Map(points.map((p) => [p.date, p])); | |
| try { | |
| const { stdout } = await execFileAsync("git", ["-C", workspacePath, "log", "--since=14 days ago", "--date=short", "--pretty=format:%cd"], { windowsHide: true }); | |
| for (const line of stdout.split(/\r?\n/)) { | |
| const key = line.trim(); | |
| const point = byDate.get(key); | |
| if (point) point.commits += 1; | |
| } | |
| } catch { | |
| // keep zero commits if git history is unavailable | |
| } | |
| let issueUpdates = []; | |
| try { | |
| const repo = await discoverRepo(workspacePath); | |
| if (!repo) throw new Error("Unable to resolve repository for gh CLI"); | |
| const repoRef = `${repo.owner}/${repo.repo}`; | |
| const { stdout } = await execFileAsync("gh", ["issue", "list", "-R", repoRef, "--label", "squad", "--state", "all", "--limit", "200", "--json", "updatedAt"], { | |
| windowsHide: true, | |
| cwd: workspacePath, | |
| }); | |
| issueUpdates = parseJson(stdout, []).map((i) => i.updatedAt).filter(Boolean); | |
| } catch { | |
| issueUpdates = await readIssueUpdatesViaPublicApi(workspacePath); | |
| } | |
| for (const stamp of issueUpdates) { | |
| const key = stamp.slice(0, 10); | |
| const point = byDate.get(key); | |
| if (point) point.issues += 1; | |
| } | |
| return points; | |
| } | |
| async function discoverRepoMeta(workspacePath) { | |
| const meta = { owner: "", repo: "", branch: "", fullName: "" }; | |
| try { | |
| const { stdout: branchOut } = await execFileAsync("git", ["-C", workspacePath, "branch", "--show-current"], { windowsHide: true }); | |
| meta.branch = branchOut.trim(); | |
| } catch { /* ignore */ } | |
| const repoInfo = await discoverRepo(workspacePath); | |
| if (repoInfo) { | |
| meta.owner = repoInfo.owner; | |
| meta.repo = repoInfo.repo; | |
| meta.fullName = `${repoInfo.owner}/${repoInfo.repo}`; | |
| } | |
| return meta; | |
| } | |
| function computeAgentWorkload(members, topIssues) { | |
| const workload = {}; | |
| for (const m of members) { | |
| workload[m.name.toLowerCase()] = { assigned: 0, issues: [] }; | |
| } | |
| for (const issue of topIssues) { | |
| const labels = (issue.labels || []).map((l) => l.name || "").filter(Boolean); | |
| for (const label of labels) { | |
| const match = label.match(/^squad:(\w+)$/i); | |
| if (match) { | |
| const agent = match[1].toLowerCase(); | |
| if (!workload[agent]) workload[agent] = { assigned: 0, issues: [] }; | |
| workload[agent].assigned += 1; | |
| workload[agent].issues.push(issue.number); | |
| } | |
| } | |
| } | |
| return workload; | |
| } | |
| async function buildSnapshot(instanceId, workspacePath) { | |
| const [squad, github, history, repoMeta] = await Promise.all([ | |
| readSquadStatus(workspacePath), | |
| readGitHubWork(workspacePath), | |
| readActivityHistory(workspacePath), | |
| discoverRepoMeta(workspacePath), | |
| ]); | |
| const agentWorkload = computeAgentWorkload(squad.members, github.topIssues); | |
| const snapshot = { | |
| instanceId, | |
| generatedAt: new Date().toISOString(), | |
| squad, | |
| github, | |
| history, | |
| repoMeta, | |
| agentWorkload, | |
| }; | |
| snapshots.set(instanceId, snapshot); | |
| return snapshot; | |
| } | |
| function getSnapshot(instanceId) { | |
| return snapshots.get(instanceId) || { | |
| instanceId, | |
| generatedAt: new Date().toISOString(), | |
| squad: { members: [], decisionCount: 0, focusArea: "Not set", decisions: [], orchestrationLogCount: 0, sessionLogCount: 0, inboxCount: 0 }, | |
| github: { allOpenIssues: 0, squadOpenIssues: 0, openPrs: 0, draftPrs: 0, reviewChangesRequested: 0, topIssues: [], dataSource: "none" }, | |
| history: [], | |
| repoMeta: { owner: "", repo: "", branch: "", fullName: "" }, | |
| agentWorkload: {}, | |
| }; | |
| } | |
| function emitSnapshot(instanceId) { | |
| const clients = subscribers.get(instanceId) || new Set(); | |
| const payload = `data: ${JSON.stringify(getSnapshot(instanceId))}\n\n`; | |
| for (const res of clients) { | |
| res.write(payload); | |
| } | |
| } | |
| function renderHtml(instanceId) { | |
| return `<!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Squad Status</title> | |
| <style> | |
| :root { color-scheme: light dark; } | |
| body { | |
| margin: 0; padding: 16px; | |
| background: var(--background-color-default, #0d1117); | |
| color: var(--text-color-default, #e6edf3); | |
| font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); | |
| font-size: var(--text-body-medium, 14px); | |
| line-height: var(--leading-body-medium, 20px); | |
| } | |
| .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; } | |
| h1 { margin: 0; font-size: var(--text-title-large, 24px); font-weight: var(--font-weight-semibold, 600); } | |
| .subtle { color: var(--text-color-muted, #8b949e); font-size: 12px; } | |
| .section { margin-top: 16px; } | |
| .layout { display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; } | |
| .panel { border: 1px solid var(--border-color-default, #30363d); border-radius: 12px; overflow: hidden; } | |
| .panel h2 { margin: 0; padding: 10px 12px; font-size: 15px; border-bottom: 1px solid var(--border-color-default, #30363d); background: color-mix(in srgb, var(--background-color-default, #0d1117) 92%, var(--text-color-default, #e6edf3) 8%); display: flex; justify-content: space-between; align-items: center; } | |
| .panel .body { padding: 10px; } | |
| table { width: 100%; border-collapse: collapse; border: 0; } | |
| th, td { padding: 8px 10px; border-bottom: 1px solid var(--border-color-default, #30363d); text-align: left; font-size: 13px; } | |
| th { color: var(--text-color-muted, #8b949e); font-weight: 600; } | |
| button { border: 1px solid var(--border-color-default, #30363d); color: var(--text-color-default, #e6edf3); background: transparent; border-radius: 8px; padding: 7px 10px; cursor: pointer; font-size: 13px; } | |
| button:hover { background: color-mix(in srgb, var(--background-color-default, #0d1117) 80%, var(--text-color-default, #e6edf3) 20%); } | |
| button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| button.primary { background: var(--true-color-blue, #388bfd); border-color: var(--true-color-blue, #388bfd); color: #fff; font-weight: 600; } | |
| button.primary:hover { opacity: 0.9; } | |
| .pill { border: 1px solid var(--border-color-default, #30363d); border-radius: 999px; padding: 2px 8px; font-size: 12px; } | |
| .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| input[type="search"] { border: 1px solid var(--border-color-default, #30363d); color: var(--text-color-default, #e6edf3); background: transparent; border-radius: 8px; padding: 7px 10px; min-width: 200px; } | |
| .decision-list { margin: 0; padding-left: 18px; } | |
| .decision-list li { margin-bottom: 6px; font-size: 13px; } | |
| .tag { display: inline-block; margin-right: 4px; margin-top: 4px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color-default, #30363d); font-size: 11px; color: var(--text-color-muted, #8b949e); } | |
| .tag.agent-tag { border-color: var(--true-color-blue-muted, #58a6ff); color: var(--true-color-blue-muted, #58a6ff); } | |
| .hero-wrap { border: 1px solid var(--border-color-default, #30363d); border-radius: 12px; padding: 12px; background: linear-gradient(165deg, color-mix(in srgb, var(--background-color-default, #0d1117) 90%, var(--true-color-blue-muted, #1f6feb) 10%), var(--background-color-default, #0d1117)); } | |
| .hero-caption { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; color: var(--text-color-muted, #8b949e); } | |
| a.issue-link { color: var(--text-color-default, #e6edf3); text-decoration: none; } | |
| a.issue-link:hover { text-decoration: underline; } | |
| .workload-badge { display: inline-block; min-width: 20px; text-align: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 700; background: var(--true-color-blue, #388bfd); color: #fff; } | |
| .workload-badge.zero { background: var(--border-color-default, #30363d); color: var(--text-color-muted, #8b949e); } | |
| .inbox-badge { display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 700; background: var(--true-color-red, #f85149); color: #fff; margin-left: 6px; } | |
| .more-link { font-size: 12px; color: var(--text-color-muted, #8b949e); cursor: pointer; margin-top: 4px; } | |
| .more-link:hover { color: var(--text-color-default, #e6edf3); } | |
| .assign-select { border: 1px solid var(--border-color-default, #30363d); color: var(--text-color-default, #e6edf3); background: var(--background-color-default, #0d1117); border-radius: 6px; padding: 2px 4px; font-size: 11px; } | |
| .toast { position: fixed; bottom: 16px; right: 16px; padding: 10px 16px; border-radius: 8px; background: var(--true-color-blue, #388bfd); color: #fff; font-size: 13px; font-weight: 600; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; } | |
| .toast.visible { opacity: 1; } | |
| @media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div> | |
| <h1>Squad Status</h1> | |
| <div class="subtle" id="meta">Loading…</div> | |
| </div> | |
| <div class="toolbar"> | |
| <input id="issueFilter" type="search" placeholder="Filter issues…" /> | |
| <button id="ralphGoBtn" class="primary">🔄 Ralph, go</button> | |
| <button id="refreshBtn">Refresh</button> | |
| </div> | |
| </div> | |
| <div class="hero-wrap"> | |
| <svg id="heroSvg" width="100%" height="250" viewBox="0 0 980 240" preserveAspectRatio="xMidYMid meet"></svg> | |
| <div class="hero-caption"> | |
| <span id="heroLeft">Initializing…</span> | |
| <span id="heroRight">—</span> | |
| </div> | |
| </div> | |
| <div class="section layout"> | |
| <div class="panel"> | |
| <h2>Squad Queue <span class="subtle" id="queueCount"></span></h2> | |
| <div class="body" style="padding:0;"> | |
| <table> | |
| <thead><tr><th style="width:60px;">Issue</th><th>Title</th><th style="width:100px;">Owner</th><th style="width:90px;">Assign</th></tr></thead> | |
| <tbody id="issues"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Team <span id="inboxBadge"></span></h2> | |
| <div class="body"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;"> | |
| <span class="pill" id="focusArea">Focus: —</span> | |
| <span class="subtle">Logs: <span id="logMeta">0 / 0</span></span> | |
| </div> | |
| <table> | |
| <thead><tr><th>Agent</th><th>Role</th><th>Status</th><th style="width:60px;">Load</th></tr></thead> | |
| <tbody id="members"></tbody> | |
| </table> | |
| <h3 style="margin:12px 0 8px 0; font-size:14px;">Decisions <span class="subtle" id="decisionMeta"></span></h3> | |
| <ol id="decisions" class="decision-list"></ol> | |
| <div id="decisionsMore" class="more-link" style="display:none;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toast" class="toast"></div> | |
| <script> | |
| const membersEl = document.getElementById("members"); | |
| const issuesEl = document.getElementById("issues"); | |
| const decisionsEl = document.getElementById("decisions"); | |
| const decisionsMore = document.getElementById("decisionsMore"); | |
| const decisionMeta = document.getElementById("decisionMeta"); | |
| const focusAreaEl = document.getElementById("focusArea"); | |
| const logMetaEl = document.getElementById("logMeta"); | |
| const metaEl = document.getElementById("meta"); | |
| const heroSvg = document.getElementById("heroSvg"); | |
| const heroLeft = document.getElementById("heroLeft"); | |
| const heroRight = document.getElementById("heroRight"); | |
| const issueFilter = document.getElementById("issueFilter"); | |
| const refreshBtn = document.getElementById("refreshBtn"); | |
| const ralphGoBtn = document.getElementById("ralphGoBtn"); | |
| const queueCount = document.getElementById("queueCount"); | |
| const inboxBadge = document.getElementById("inboxBadge"); | |
| const toastEl = document.getElementById("toast"); | |
| let latest = null; | |
| let showAllDecisions = false; | |
| function esc(v) { return String(v||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); } | |
| function pct(v, t) { return (!t||t<=0)?0:Math.max(0,Math.min(100,Math.round((v/t)*100))); } | |
| function issueLabelNames(issue) { return (issue.labels||[]).map(l=>l.name||"").filter(Boolean); } | |
| function agentForIssue(issue) { | |
| for (const l of (issue.labels||[])) { | |
| const m = (l.name||"").match(/^squad:(\\w+)$/i); | |
| if (m) return m[1]; | |
| } | |
| return null; | |
| } | |
| function showToast(msg) { | |
| toastEl.textContent = msg; | |
| toastEl.classList.add('visible'); | |
| setTimeout(() => toastEl.classList.remove('visible'), 3000); | |
| } | |
| function utilizationBar(label, value, total, x, y, width, color) { | |
| const p = pct(value, Math.max(total, 1)); | |
| const fillW = Math.round((p / 100) * width); | |
| return [ | |
| '<text x="'+x+'" y="'+(y-6)+'" fill="var(--text-color-muted,#8b949e)" font-size="12">'+esc(label)+'</text>', | |
| '<text x="'+(x+width)+'" y="'+(y-6)+'" text-anchor="end" fill="var(--text-color-default,#e6edf3)" font-size="12" font-weight="700">'+value+'/'+total+'</text>', | |
| '<rect x="'+x+'" y="'+y+'" width="'+width+'" height="10" rx="5" fill="var(--border-color-default,#30363d)" />', | |
| '<rect x="'+x+'" y="'+y+'" width="'+fillW+'" height="10" rx="5" fill="'+color+'" />', | |
| ].join(''); | |
| } | |
| function workloadChart(agentWorkload, members, x, y, width) { | |
| if (!members.length) return ''; | |
| const barH = 14, gap = 6, labelW = 80; | |
| const maxLoad = Math.max(1, ...Object.values(agentWorkload || {}).map(w => w.assigned || 0)); | |
| const barsW = width - labelW - 10; | |
| let out = '<text x="'+x+'" y="'+(y-8)+'" fill="var(--text-color-muted,#8b949e)" font-size="12">Agent workload</text>'; | |
| members.forEach((m, i) => { | |
| const key = m.name.toLowerCase(); | |
| const load = (agentWorkload||{})[key]?.assigned || 0; | |
| const rowY = y + i * (barH + gap); | |
| const fillW = Math.round((load / maxLoad) * barsW); | |
| out += '<text x="'+x+'" y="'+(rowY+11)+'" fill="var(--text-color-default,#e6edf3)" font-size="11">'+esc(m.name)+'</text>'; | |
| out += '<rect x="'+(x+labelW)+'" y="'+rowY+'" width="'+barsW+'" height="'+barH+'" rx="4" fill="var(--border-color-default,#30363d)" />'; | |
| if (fillW > 0) out += '<rect x="'+(x+labelW)+'" y="'+rowY+'" width="'+fillW+'" height="'+barH+'" rx="4" fill="var(--true-color-blue-muted,#58a6ff)" />'; | |
| out += '<text x="'+(x+labelW+barsW+6)+'" y="'+(rowY+11)+'" fill="var(--text-color-default,#e6edf3)" font-size="11" font-weight="700">'+load+'</text>'; | |
| }); | |
| return out; | |
| } | |
| function trendChart(history, x, y, width, height) { | |
| const points = Array.isArray(history) && history.length ? history : []; | |
| if (!points.length) return '<text x="'+x+'" y="'+(y+18)+'" fill="var(--text-color-muted,#8b949e)" font-size="12">No trend data yet</text>'; | |
| const maxV = Math.max(1, ...points.map(p => Math.max(p.issues||0, p.commits||0))); | |
| const step = points.length > 1 ? width / (points.length - 1) : width; | |
| let commitPath='', issuePath='', issueBars='', ticks=''; | |
| for (let i=0; i<points.length; i++) { | |
| const p = points[i], px = x + i * step; | |
| const issueY = y + height - ((p.issues||0) / maxV) * height; | |
| const commitY = y + height - ((p.commits||0) / maxV) * height; | |
| issuePath += (i===0?'M':' L')+px+' '+issueY; | |
| commitPath += (i===0?'M':' L')+px+' '+commitY; | |
| const barW = Math.max(4, Math.floor(step * 0.5)); | |
| issueBars += '<rect x="'+(px-barW/2)+'" y="'+issueY+'" width="'+barW+'" height="'+(y+height-issueY)+'" rx="2" fill="var(--true-color-blue-muted,#58a6ff)" opacity="0.3" />'; | |
| if (i%3===0 || i===points.length-1) ticks += '<text x="'+px+'" y="'+(y+height+14)+'" text-anchor="middle" fill="var(--text-color-muted,#8b949e)" font-size="10">'+esc(p.label)+'</text>'; | |
| } | |
| return [ | |
| '<line x1="'+x+'" y1="'+(y+height)+'" x2="'+(x+width)+'" y2="'+(y+height)+'" stroke="var(--border-color-default,#30363d)" stroke-width="1" />', | |
| issueBars, | |
| '<path d="'+issuePath+'" fill="none" stroke="var(--true-color-blue,#388bfd)" stroke-width="2" />', | |
| '<path d="'+commitPath+'" fill="none" stroke="var(--true-color-red,#f85149)" stroke-width="2" />', | |
| ticks, | |
| '<text x="'+x+'" y="'+(y-8)+'" fill="var(--text-color-muted,#8b949e)" font-size="12">14d: issue updates (blue) · commits (red)</text>', | |
| ].join(''); | |
| } | |
| function renderHero(snapshot) { | |
| const { squad, github, agentWorkload } = snapshot; | |
| const w = Math.max(680, Math.floor(heroSvg.clientWidth || 980)); | |
| const h = 240; | |
| heroSvg.setAttribute('viewBox', '0 0 ' + w + ' ' + h); | |
| const members = squad.members || []; | |
| const workloads = agentWorkload || {}; | |
| const activeMembers = members.filter((m) => (workloads[m.name.toLowerCase()]?.assigned || 0) > 0).length; | |
| const totalAssignments = Object.values(workloads).reduce((sum, workload) => sum + (workload?.assigned || 0), 0); | |
| const totalMembers = Math.max(members.length, 1); | |
| const leftW = Math.max(260, Math.floor(w * 0.38)); | |
| const metricX = 24; | |
| const rightX = metricX + leftW + 28; | |
| const rightW = Math.max(220, w - rightX - 20); | |
| const markup = [ | |
| '<rect x="0" y="0" width="'+w+'" height="'+h+'" rx="12" fill="transparent"/>', | |
| utilizationBar('Active agents (assigned)', activeMembers, totalMembers, metricX, 36, leftW, 'var(--true-color-red-muted,#ff7b72)'), | |
| workloadChart(agentWorkload, members, metricX, 68, leftW), | |
| trendChart(snapshot.history, rightX, 36, rightW, 150), | |
| ].join(''); | |
| heroSvg.innerHTML = markup; | |
| const unassigned = (github.topIssues||[]).filter(i => !agentForIssue(i)).length; | |
| heroLeft.textContent = unassigned > 0 | |
| ? unassigned + ' unassigned issue' + (unassigned>1?'s':'') + ' need triage' | |
| : totalAssignments + ' active assignment' + (totalAssignments===1 ? '' : 's') + ' across ' + activeMembers + ' agent' + (activeMembers===1 ? '' : 's'); | |
| heroRight.textContent = github.ghUnavailable ? '⚠ degraded' : '● ' + (github.dataSource || 'live'); | |
| } | |
| function render(snapshot) { | |
| const { squad, github, generatedAt, repoMeta, agentWorkload } = snapshot; | |
| latest = snapshot; | |
| // Subtitle: repo + branch | |
| const repo = (repoMeta && repoMeta.fullName) ? repoMeta.fullName : ''; | |
| const branch = (repoMeta && repoMeta.branch) ? repoMeta.branch : ''; | |
| const ts = new Date(generatedAt).toLocaleTimeString(); | |
| const source = github.dataSource ? ' · ' + github.dataSource : ''; | |
| metaEl.textContent = (repo ? repo + (branch ? ' @ ' + branch : '') + ' · ' : '') + 'Updated ' + ts + source; | |
| // Team table with workload badges | |
| membersEl.innerHTML = (squad.members || []).map(m => { | |
| const key = m.name.toLowerCase(); | |
| const load = (agentWorkload||{})[key]?.assigned || 0; | |
| const badgeCls = load > 0 ? 'workload-badge' : 'workload-badge zero'; | |
| return '<tr><td><strong>' + esc(m.name) + '</strong></td><td>' + esc(m.role) + '</td><td>' + (m.status||"—") + '</td><td><span class="'+badgeCls+'">'+load+'</span></td></tr>'; | |
| }).join("") || '<tr><td colspan="4">No members found</td></tr>'; | |
| // Issues with owner column + assign dropdown | |
| const q = (issueFilter.value || '').trim().toLowerCase(); | |
| const filtered = (github.topIssues || []).filter(i => { | |
| if (!q) return true; | |
| const labels = issueLabelNames(i).join(' ').toLowerCase(); | |
| return (i.title||'').toLowerCase().includes(q) || labels.includes(q); | |
| }); | |
| const memberNames = (squad.members||[]).map(m => m.name); | |
| issuesEl.innerHTML = filtered.map(i => { | |
| const owner = agentForIssue(i); | |
| const ownerHtml = owner ? '<span class="tag agent-tag">' + esc(owner) + '</span>' : '<span class="subtle">—</span>'; | |
| const opts = memberNames.map(n => '<option value="'+esc(n)+'"'+(owner&&owner.toLowerCase()===n.toLowerCase()?' selected':'')+'>'+esc(n)+'</option>').join(''); | |
| const assignHtml = '<select class="assign-select" data-issue="'+i.number+'"><option value="">assign…</option>'+opts+'</select>'; | |
| return '<tr><td><a class="issue-link" href="'+esc(i.url||'#')+'" target="_blank">#'+i.number+'</a></td><td>'+esc(i.title)+'</td><td>'+ownerHtml+'</td><td>'+assignHtml+'</td></tr>'; | |
| }).join("") || '<tr><td colspan="4">No squad-labeled issues</td></tr>'; | |
| queueCount.textContent = filtered.length > 0 ? '(' + filtered.length + ')' : ''; | |
| // Decisions with truncation | |
| const allDecs = squad.decisions || []; | |
| const shown = showAllDecisions ? allDecs : allDecs.slice(0, 5); | |
| decisionsEl.innerHTML = shown.map(d => '<li><strong>'+esc(d.id)+'</strong> — '+esc(d.title)+'</li>').join('') || '<li>No decisions recorded.</li>'; | |
| decisionMeta.textContent = '(' + (squad.decisionCount||0) + ' total)'; | |
| if (allDecs.length > 5 && !showAllDecisions) { | |
| decisionsMore.style.display = 'block'; | |
| decisionsMore.textContent = '▸ Show all ' + allDecs.length + ' recent decisions'; | |
| } else { | |
| decisionsMore.style.display = 'none'; | |
| } | |
| // Inbox badge | |
| const inbox = squad.inboxCount || 0; | |
| inboxBadge.innerHTML = inbox > 0 ? '<span class="inbox-badge">' + inbox + ' inbox</span>' : ''; | |
| focusAreaEl.textContent = "Focus: " + (squad.focusArea || "Not set"); | |
| logMetaEl.textContent = (squad.orchestrationLogCount||0) + ' orch / ' + (squad.sessionLogCount||0) + ' session'; | |
| renderHero(snapshot); | |
| } | |
| // --- Interactions --- | |
| async function load() { | |
| const res = await fetch("/api/status"); | |
| render(await res.json()); | |
| } | |
| refreshBtn.addEventListener("click", async () => { | |
| refreshBtn.disabled = true; | |
| await fetch("/api/refresh", { method: "POST" }); | |
| refreshBtn.disabled = false; | |
| }); | |
| ralphGoBtn.addEventListener("click", async () => { | |
| ralphGoBtn.disabled = true; | |
| ralphGoBtn.textContent = '⏳ Scanning…'; | |
| const res = await fetch("/api/ralph-go", { method: "POST" }); | |
| const data = await res.json(); | |
| ralphGoBtn.textContent = '🔄 Ralph, go'; | |
| ralphGoBtn.disabled = false; | |
| if (data.summary) showToast(data.summary); | |
| }); | |
| issueFilter.addEventListener("input", () => { if (latest) render(latest); }); | |
| window.addEventListener("resize", () => { if (latest) renderHero(latest); }); | |
| decisionsMore.addEventListener("click", () => { | |
| showAllDecisions = true; | |
| if (latest) render(latest); | |
| }); | |
| document.addEventListener("change", async (e) => { | |
| if (e.target.classList.contains("assign-select")) { | |
| const issueNum = e.target.dataset.issue; | |
| const agent = e.target.value; | |
| if (!agent || !issueNum) return; | |
| e.target.disabled = true; | |
| const res = await fetch("/api/assign", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ issue: Number(issueNum), agent }) }); | |
| const data = await res.json(); | |
| e.target.disabled = false; | |
| if (data.ok) showToast('Assigned #' + issueNum + ' → ' + agent); | |
| else showToast('Assign failed: ' + (data.error || 'unknown')); | |
| } | |
| }); | |
| const es = new EventSource("/events"); | |
| es.onmessage = (evt) => { try { render(JSON.parse(evt.data)); } catch {} }; | |
| load(); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| async function startServer(instanceId, workspacePath) { | |
| const resolveRoot = () => instanceRoots.get(instanceId) || workspacePath; | |
| const interval = setInterval(async () => { | |
| await buildSnapshot(instanceId, resolveRoot()); | |
| emitSnapshot(instanceId); | |
| }, 30_000); | |
| interval.unref(); | |
| const server = createServer(async (req, res) => { | |
| const url = new URL(req.url || "/", "http://127.0.0.1"); | |
| if (req.method === "GET" && url.pathname === "/") { | |
| res.setHeader("Content-Type", "text/html; charset=utf-8"); | |
| res.end(renderHtml(instanceId)); | |
| return; | |
| } | |
| if (req.method === "GET" && url.pathname === "/api/status") { | |
| let snap = snapshots.get(instanceId); | |
| if (!snap || !snap.squad?.members?.length) { | |
| snap = await buildSnapshot(instanceId, resolveRoot()); | |
| } | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify(snap)); | |
| return; | |
| } | |
| if (req.method === "POST" && url.pathname === "/api/refresh") { | |
| const snapshot = await buildSnapshot(instanceId, resolveRoot()); | |
| emitSnapshot(instanceId); | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify({ ok: true, generatedAt: snapshot.generatedAt })); | |
| return; | |
| } | |
| if (req.method === "POST" && url.pathname === "/api/ralph-go") { | |
| const before = getSnapshot(instanceId); | |
| const snapshot = await buildSnapshot(instanceId, resolveRoot()); | |
| emitSnapshot(instanceId); | |
| const beforeIssues = before.github?.squadOpenIssues || 0; | |
| const afterIssues = snapshot.github?.squadOpenIssues || 0; | |
| const issueDelta = afterIssues - beforeIssues; | |
| const summary = `Scanned: ${snapshot.squad?.members?.length || 0} agents, ${afterIssues} issues` + (issueDelta !== 0 ? ` (${issueDelta > 0 ? "+" : ""}${issueDelta})` : "") + `, ${snapshot.squad?.decisionCount || 0} decisions`; | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify({ ok: true, summary })); | |
| return; | |
| } | |
| if (req.method === "POST" && url.pathname === "/api/assign") { | |
| let body = ""; | |
| for await (const chunk of req) body += chunk; | |
| try { | |
| const { issue, agent } = JSON.parse(body); | |
| if (!issue || !agent) throw new Error("Missing issue or agent"); | |
| const label = `squad:${agent}`; | |
| const repoInfo = await discoverRepo(resolveRoot()); | |
| if (!repoInfo) throw new Error("Cannot determine repo"); | |
| // Try gh CLI first, fall back to API | |
| try { | |
| await execFileAsync("gh", ["issue", "edit", String(issue), "--add-label", label, "-R", `${repoInfo.owner}/${repoInfo.repo}`], { windowsHide: true }); | |
| } catch { | |
| const resp = await fetch(`https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${issue}/labels`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", Accept: "application/vnd.github+json" }, | |
| body: JSON.stringify({ labels: [label] }), | |
| }); | |
| if (!resp.ok) throw new Error(`GitHub API: ${resp.status}`); | |
| } | |
| // Rebuild snapshot to reflect label change | |
| const snapshot = await buildSnapshot(instanceId, resolveRoot()); | |
| emitSnapshot(instanceId); | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify({ ok: true })); | |
| } catch (err) { | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify({ ok: false, error: err.message })); | |
| } | |
| return; | |
| } | |
| if (req.method === "GET" && url.pathname === "/events") { | |
| res.writeHead(200, { | |
| "Content-Type": "text/event-stream", | |
| "Cache-Control": "no-cache", | |
| Connection: "keep-alive", | |
| }); | |
| if (!subscribers.has(instanceId)) subscribers.set(instanceId, new Set()); | |
| const set = subscribers.get(instanceId); | |
| set.add(res); | |
| res.write(`data: ${JSON.stringify(getSnapshot(instanceId))}\n\n`); | |
| req.on("close", () => { | |
| set.delete(res); | |
| }); | |
| return; | |
| } | |
| res.statusCode = 404; | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify({ error: "not_found" })); | |
| }); | |
| // Port 0 = let the OS pick a free ephemeral port. Bind to loopback only. | |
| 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}/`, interval }; | |
| } | |
| const session = await joinSession({ | |
| canvases: [ | |
| createCanvas({ | |
| id: "squad-status", | |
| displayName: "Squad Status", | |
| description: "Live dashboard of squad roster, queue, and decision health.", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| workspacePath: { type: "string" }, | |
| }, | |
| additionalProperties: false, | |
| }, | |
| actions: [ | |
| { | |
| name: "refresh_status", | |
| description: "Refresh Squad status data from .squad files and GitHub.", | |
| handler: async (ctx) => { | |
| const requestedPath = typeof ctx.input?.workspacePath === "string" && ctx.input.workspacePath.trim() | |
| ? ctx.input.workspacePath | |
| : null; | |
| const preferredPath = requestedPath || instanceRoots.get(ctx.instanceId); | |
| const root = await resolvePanelWorkspaceRoot(ctx.instanceId, preferredPath); | |
| instanceRoots.set(ctx.instanceId, root); | |
| const snapshot = await buildSnapshot(ctx.instanceId, root); | |
| emitSnapshot(ctx.instanceId); | |
| return snapshot; | |
| }, | |
| }, | |
| { | |
| name: "get_status", | |
| description: "Return the last known Squad status snapshot for this panel.", | |
| handler: async (ctx) => { | |
| return getSnapshot(ctx.instanceId); | |
| }, | |
| }, | |
| ], | |
| open: async (ctx) => { | |
| const root = await resolvePanelWorkspaceRoot(ctx.instanceId, ctx.input?.workspacePath); | |
| instanceRoots.set(ctx.instanceId, root); | |
| let entry = servers.get(ctx.instanceId); | |
| if (!entry) { | |
| entry = await startServer(ctx.instanceId, root); | |
| servers.set(ctx.instanceId, entry); | |
| } | |
| await buildSnapshot(ctx.instanceId, root); | |
| return { | |
| title: "Squad Status", | |
| status: "Live", | |
| url: entry.url, | |
| }; | |
| }, | |
| onClose: async (ctx) => { | |
| const entry = servers.get(ctx.instanceId); | |
| if (entry) { | |
| servers.delete(ctx.instanceId); | |
| clearInterval(entry.interval); | |
| snapshots.delete(ctx.instanceId); | |
| subscribers.delete(ctx.instanceId); | |
| instanceRoots.delete(ctx.instanceId); | |
| await new Promise((resolve) => entry.server.close(() => resolve())); | |
| } | |
| }, | |
| }), | |
| ], | |
| }); | |
| await session.log("Squad Status canvas extension loaded.", { level: "info", ephemeral: true }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment