Skip to content

Instantly share code, notes, and snippets.

@jongalloway
Last active June 8, 2026 19:02
Show Gist options
  • Select an option

  • Save jongalloway/af4e7397d9f3703aa1339b43aeb7ebe0 to your computer and use it in GitHub Desktop.

Select an option

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.
{
"name": "squad-status",
"version": 1
}
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
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