// Extension: signals-dashboard
// Live dashboard showing agent signals from workshop desks.
// Scans desks/*/.signals/ for JSON files, renders the latest signal per desk.
// Supports stashing desks (48hr hold) and restoring them.
import { createServer } from "node:http";
import { readdir, readFile, writeFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
const servers = new Map();
const STASH_TTL_MS = 48 * 60 * 60 * 1000;
// Desk names are single path segments (folder names under desks/ or classroom/).
// Reject anything that could escape the workshop dir via path traversal.
function isValidDeskName(name) {
return typeof name === "string" && name.length > 0 && name.length <= 128 &&
!name.includes("/") && !name.includes("\\") && !name.includes("\0") &&
name !== "." && name !== "..";
}
// Signal JSON is agent-produced and unvalidated. Coerce numeric fields before
// they reach the renderer so a nonnumeric value cannot inject markup or break
// layout. toScore clamps self-assessment/quality scores to 0..max; toCount
// keeps token counts as finite nonnegative integers.
function toScore(v, max = 5) {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(max, n));
}
function toCount(v) {
const n = Number(v);
if (!Number.isFinite(n) || n < 0) return 0;
return Math.floor(n);
}
// Reject cross-site POSTs to the state-changing /api/* routes (CSRF). The panel
// loads as a top-level loopback document, so its own fetches are same-origin
// (Origin === our loopback origin) and header-less / non-web-scheme callers fall
// through as allowed; a browser page on another origin is blocked.
function isCrossSiteRequest(req) {
const origin = req.headers.origin;
if (origin) {
if (origin === `http://${req.headers.host}`) return false;
if (origin === "null") return true;
if (/^https?:\/\//i.test(origin)) return true;
return false;
}
const site = req.headers["sec-fetch-site"];
return site === "cross-site" || site === "same-site";
}
// --- Stash management ---
async function readStash(workshopDir) {
const fp = join(workshopDir, ".desk-stash.json");
try {
const raw = await readFile(fp, "utf-8");
const stash = JSON.parse(raw);
const now = Date.now();
const live = stash.filter(e => (now - new Date(e.stashedAt).getTime()) < STASH_TTL_MS);
if (live.length !== stash.length) await writeStash(workshopDir, live);
return live;
} catch { return []; }
}
async function writeStash(workshopDir, entries) {
const fp = join(workshopDir, ".desk-stash.json");
await writeFile(fp, JSON.stringify(entries, null, 2), "utf-8");
}
async function stashDesk(workshopDir, deskName) {
const stash = await readStash(workshopDir);
if (stash.some(e => e.name === deskName)) return stash;
stash.push({ name: deskName, stashedAt: new Date().toISOString() });
await writeStash(workshopDir, stash);
return stash;
}
async function restoreDesk(workshopDir, deskName) {
let stash = await readStash(workshopDir);
stash = stash.filter(e => e.name !== deskName);
await writeStash(workshopDir, stash);
return stash;
}
// --- Signal reading ---
async function scanSignals(workshopDir) {
const results = [];
for (const subdir of ["desks", "classroom"]) {
const parent = join(workshopDir, subdir);
let entries;
try { entries = await readdir(parent, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const sigDir = join(parent, entry.name, ".signals");
let sigFiles;
try { sigFiles = await readdir(sigDir); }
catch {
results.push({
deskName: entry.name, signalType: "none", agentName: entry.name,
confidence: 0, accuracy: 0, completeness: 0, intent: 0,
whatWorked: "", whatWasHard: "", skillGap: "",
escalationReason: null, escalationBlocked: null, recommendation: null,
emittedAt: null, signalCount: 0, tokensIn: 0, tokensOut: 0, model: null,
});
continue;
}
const jsonFiles = sigFiles.filter(f => f.endsWith(".json"));
if (jsonFiles.length === 0) {
results.push({
deskName: entry.name, signalType: "none", agentName: entry.name,
confidence: 0, accuracy: 0, completeness: 0, intent: 0,
whatWorked: "", whatWasHard: "", skillGap: "",
escalationReason: null, escalationBlocked: null, recommendation: null,
emittedAt: null, signalCount: 0, tokensIn: 0, tokensOut: 0, model: null,
});
continue;
}
// Read all signals, separate by type, find latest execution/partnership + any outcome signals
let latest = null, latestTime = 0;
const allSignals = [];
for (const f of jsonFiles) {
const fp = join(sigDir, f);
try {
const s = await stat(fp);
const raw = await readFile(fp, "utf-8");
const parsed = JSON.parse(raw);
allSignals.push({ parsed, mtimeMs: s.mtimeMs, path: fp });
// Latest non-outcome signal (execution, partnership, escalation)
if ((parsed.signal_type || "execution") !== "outcome" && s.mtimeMs > latestTime) {
latestTime = s.mtimeMs; latest = { parsed, mtimeMs: s.mtimeMs };
}
} catch {}
}
if (!latest) continue;
try {
const sig = latest.parsed;
const intentRaw = sig.intent || sig.self_assessment?.intent || null;
// Find outcome signal matched by run_id (if any)
let outcome = null;
if (sig.run_id) {
const outcomeSignals = allSignals
.filter(s => s.parsed.signal_type === "outcome" && s.parsed.run_id === sig.run_id);
if (outcomeSignals.length > 0) {
outcome = outcomeSignals.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].parsed;
}
}
// Also check for any recent outcome (within 1hr of latest signal) if no run_id match
if (!outcome) {
const recentOutcomes = allSignals
.filter(s => s.parsed.signal_type === "outcome" && s.mtimeMs >= latestTime && (s.mtimeMs - latestTime) < 3600000)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
if (recentOutcomes.length > 0) outcome = recentOutcomes[0].parsed;
}
// Compute honesty gap if we have both self-assessment and outcome
let honestyGap = null;
if (outcome && sig.self_assessment) {
const selfConf = toScore(sig.self_assessment.confidence);
const outcomeRating = toScore(outcome.quality_rating);
if (selfConf > 0 && outcomeRating > 0) {
honestyGap = Math.abs(selfConf - outcomeRating);
}
}
results.push({
deskName: entry.name,
signalType: sig.signal_type || "execution",
subtype: sig.subtype || sig.signal_type || "execution",
agentName: sig.agent_name || entry.name,
intentText: typeof intentRaw === "string" ? intentRaw : null,
intentScore: toScore(intentRaw),
confidence: toScore(sig.self_assessment?.confidence),
accuracy: toScore(sig.self_assessment?.accuracy),
completeness: toScore(sig.self_assessment?.completeness),
whatWorked: sig.patterns?.what_worked || "",
whatWasHard: sig.patterns?.what_was_hard || "",
skillGap: sig.patterns?.skill_gap || "",
escalationReason: sig.escalation?.reason || null,
escalationBlocked: sig.escalation?.blocked_on || null,
recommendation: sig.escalation?.recommendation || null,
emittedAt: new Date(latestTime).toISOString(),
signalCount: jsonFiles.length,
tokensIn: toCount(sig.usage?.tokens_in),
tokensOut: toCount(sig.usage?.tokens_out),
model: sig.usage?.model || null,
// Outcome signal fields
outcomeRating: outcome ? (toScore(outcome.quality_rating) || null) : null,
outcomeEffort: outcome?.effort_to_merge || null,
outcomeIssues: Array.isArray(outcome?.issues_found) ? outcome.issues_found : [],
outcomeAgent: outcome?.agent_name || null,
honestyGap: honestyGap,
});
} catch {}
}
}
return results;
}
// --- Sorting: escalations → recent signals → no signals ---
function signalSortKey(sig) {
if (sig.signalType === "escalation") return 0;
if (sig.signalType === "execution") return 1;
if (sig.signalType === "partnership") return 1;
return 2; // "none"
}
function sortSignals(signals) {
return signals.sort((a, b) => {
const ka = signalSortKey(a), kb = signalSortKey(b);
if (ka !== kb) return ka - kb;
if (a.emittedAt && b.emittedAt) return new Date(b.emittedAt) - new Date(a.emittedAt);
if (a.emittedAt) return -1;
if (b.emittedAt) return 1;
return a.deskName.localeCompare(b.deskName);
});
}
// --- HTML rendering ---
function esc(s) {
return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function truncate(s, len) {
const str = String(s);
return str.length > len ? str.slice(0, len) + "…" : str;
}
function formatTokens(n) {
if (!n) return null;
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return `${n}`;
}
function scoreBar(value, label, max = 5) {
const pct = (value / max) * 100;
const color = value >= 4 ? "#22c55e" : value >= 3 ? "#eab308" : value >= 1 ? "#ef4444" : "#262626";
return `
`;
}
function timeSince(isoDate) {
if (!isoDate) return "—";
const diff = Date.now() - new Date(isoDate).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function timeRemaining(stashedAt) {
const remaining = STASH_TTL_MS - (Date.now() - new Date(stashedAt).getTime());
if (remaining <= 0) return "expiring";
const hrs = Math.floor(remaining / 3600000);
return hrs >= 1 ? `${hrs}h left` : `${Math.floor(remaining / 60000)}m left`;
}
function avgScore(signals) {
const withSignals = signals.filter(s => s.signalType !== "none");
if (withSignals.length === 0) return null;
const avg = (field) => {
const vals = withSignals.map(s => s[field]).filter(v => v > 0);
return vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : "—";
};
return { confidence: avg("confidence"), accuracy: avg("accuracy"), completeness: avg("completeness"), intent: avg("intentScore") };
}
function renderSummaryBar(activeSignals) {
const escalations = activeSignals.filter(s => s.signalType === "escalation").length;
const withSignals = activeSignals.filter(s => s.signalType !== "none").length;
const awaiting = activeSignals.filter(s => s.signalType === "none").length;
const avg = avgScore(activeSignals);
const totalTokens = activeSignals.reduce((sum, s) => sum + (s.tokensIn || 0) + (s.tokensOut || 0), 0);
const withOutcomes = activeSignals.filter(s => s.outcomeRating !== null).length;
const avgGap = (() => {
const gaps = activeSignals.filter(s => s.honestyGap !== null).map(s => s.honestyGap);
return gaps.length ? (gaps.reduce((a, b) => a + b, 0) / gaps.length).toFixed(1) : null;
})();
const escBadge = escalations > 0
? `⚠ ${escalations} escalation${escalations > 1 ? "s" : ""}`
: "";
const tokenBadge = totalTokens > 0
? `🪙 ${formatTokens(totalTokens)}`
: "";
const calibrationBadge = withOutcomes > 0
? `🔍 gap ${avgGap}`
: "";
const avgBlock = avg ? `
intent ${avg.intent}
conf ${avg.confidence}
acc ${avg.accuracy}
comp ${avg.completeness}
` : "";
return `
${activeSignals.length} desk${activeSignals.length !== 1 ? "s" : ""}
${withSignals} reporting · ${awaiting} awaiting
${tokenBadge}
${calibrationBadge}
${escBadge}
${avgBlock}
`;
}
function renderSignalCard(sig) {
const isEscalation = sig.signalType === "escalation";
const isPartnership = sig.signalType === "partnership";
const noSignal = sig.signalType === "none";
const borderColor = isEscalation ? "#dc2626" : noSignal ? "#1e293b" : "#1e3a5f";
const bgColor = isEscalation ? "#0f0604" : "#0f172a";
const typeLabel = isEscalation
? (sig.subtype === "blocked"
? `⚠ BLOCKED`
: `⚠ HANDS-UP`)
: noSignal
? `📡 awaiting`
: isPartnership
? `🤝 partnership`
: sig.subtype === "done"
? `✓ done`
: `✓ checkpoint`;
const stashBtn = ``;
const openBtnStyle = isEscalation
? "background:#7f1d1d;border:1px solid #dc2626;color:#fca5a5;padding:2px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;transition:all .15s;"
: "background:none;border:1px solid #1e3a5f;color:#7dd3fc;padding:2px 8px;border-radius:4px;font-size:11px;cursor:pointer;transition:all .15s;";
const openBtn = noSignal ? "" : ``;
let escalationBlock = "";
if (isEscalation && sig.escalationReason) {
escalationBlock = `
Blocked on:
${esc(sig.escalationBlocked || sig.escalationReason)}
${sig.recommendation ? `
→ ${esc(sig.recommendation)}
` : ""}
`;
}
// --- Intent text (execution signals with text intent) ---
const intentBlock = sig.intentText ? `
${esc(sig.intentText)}
` : "";
// --- Scores: shown for partnership signals, or legacy execution signals with numeric scores ---
const hasScores = isPartnership
? true
: (sig.intentScore > 0 || sig.confidence > 0 || sig.accuracy > 0 || sig.completeness > 0);
const scoresBlock = noSignal ? `
No signals yet — this desk is waiting for its first session.
` : isPartnership ? `
${scoreBar(sig.intentScore, "intent")}
${scoreBar(sig.confidence, "confidence")}
${scoreBar(sig.accuracy, "accuracy")}
${scoreBar(sig.completeness, "completeness")}
` : hasScores ? `
scores
${sig.intentScore > 0 ? scoreBar(sig.intentScore, "intent") : ""}
${sig.confidence > 0 ? scoreBar(sig.confidence, "confidence") : ""}
${sig.accuracy > 0 ? scoreBar(sig.accuracy, "accuracy") : ""}
${sig.completeness > 0 ? scoreBar(sig.completeness, "completeness") : ""}
` : "";
// --- Patterns: primary for execution, secondary for partnership ---
const patternsBlock = (sig.whatWorked || sig.whatWasHard || sig.skillGap) ? `
${sig.whatWorked ? `
✓${esc(truncate(sig.whatWorked, 160))}
` : ""}
${sig.whatWasHard ? `
△${esc(truncate(sig.whatWasHard, 160))}
` : ""}
${sig.skillGap ? `
✗${esc(truncate(sig.skillGap, 160))}
` : ""}
` : "";
// --- Outcome signal / honesty gap ---
let outcomeBlock = "";
if (sig.outcomeRating !== null && !noSignal) {
const gapColor = sig.honestyGap === null ? "#475569"
: sig.honestyGap <= 1 ? "#22c55e"
: sig.honestyGap === 2 ? "#eab308"
: "#ef4444";
const gapLabel = sig.honestyGap === null ? "—"
: sig.honestyGap <= 1 ? "well-calibrated"
: sig.honestyGap === 2 ? "moderate gap"
: "significant gap";
const effortColor = sig.outcomeEffort === "minimal" ? "#22c55e"
: sig.outcomeEffort === "moderate" ? "#eab308"
: sig.outcomeEffort === "significant" ? "#ef4444" : "#475569";
outcomeBlock = `
🔍 outcome${sig.outcomeAgent ? ` · ${esc(sig.outcomeAgent)}` : ""}
${sig.honestyGap !== null ? `${gapLabel} (gap: ${sig.honestyGap})` : ""}
quality
${sig.outcomeRating}/5
${esc(sig.outcomeEffort || "—")} effort
${sig.outcomeIssues?.length ? `
${sig.outcomeIssues.map(i => `
· ${esc(truncate(i, 120))}
`).join("")}
` : ""}
`;
}
return `
${esc(sig.deskName)}
${typeLabel}
${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""}
${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""}
${openBtn}
${stashBtn}
${isPartnership ? `${scoresBlock}${patternsBlock}` : `${intentBlock}${patternsBlock}${scoresBlock}`}
${outcomeBlock}
${escalationBlock}
`;
}
function renderStashedCard(entry) {
return `
${esc(entry.name)}
${timeRemaining(entry.stashedAt)}
`;
}
function renderDashboard(signals, stashed) {
const activeSignals = sortSignals(signals.filter(s => !stashed.some(e => e.name === s.deskName)));
const cards = activeSignals.length > 0
? activeSignals.map(renderSignalCard).join("")
: `
🪨
No active desks yet
Get started
Ask the Workshop TA in chat:
"open a desk called scanning in ~/my-workshop"
The TA uses the desk-open skill to create a desk with a journal. Once a desk emits signals, they'll appear here automatically.
💡 Quick commands to try:
• "open a desk for code review"
• "what's everyone working on?"
• "show me the signals"
`;
const summaryBar = activeSignals.length > 0 ? renderSummaryBar(activeSignals) : "";
const stashedSection = stashed.length > 0 ? `
Stashed · ${stashed.length}
${stashed.map(renderStashedCard).join("")}
` : "";
return `
Cairn · Signals
🪨 Cairn
live
${summaryBar}
${cards}
${stashedSection}
`;
}
// --- Server ---
async function startServer(instanceId, workshopDir) {
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" }));
return;
}
if (req.method === "POST" && url.pathname.startsWith("/api/stash/")) {
const deskName = decodeURIComponent(url.pathname.split("/api/stash/")[1]);
if (!isValidDeskName(deskName)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid desk name" }));
return;
}
await stashDesk(workshopDir, deskName);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "POST" && url.pathname.startsWith("/api/restore/")) {
const deskName = decodeURIComponent(url.pathname.split("/api/restore/")[1]);
if (!isValidDeskName(deskName)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid desk name" }));
return;
}
await restoreDesk(workshopDir, deskName);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "POST" && url.pathname.startsWith("/api/open/")) {
const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]);
if (!isValidDeskName(deskName)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid desk name" }));
return;
}
for (const subdir of ["desks", "classroom"]) {
const deskPath = join(workshopDir, subdir, deskName);
try {
const s = await stat(deskPath);
if (s.isDirectory()) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, deskName, deskPath }));
return;
}
} catch {}
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Desk not found" }));
return;
}
const signals = await scanSignals(workshopDir);
const stashed = await readStash(workshopDir);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderDashboard(signals, stashed));
} catch (err) {
// Top-level boundary: never leave a request hanging or let a
// rejection become an unhandled crash — e.g. malformed %-encoding
// in the path, a read-only workshop on a stash write, or a scan
// failure. Return a controlled error instead.
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "internal_error" }));
} else {
try { res.end(); } catch {}
}
}
});
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}/` };
}
// --- Canvas registration ---
const session = await joinSession({
canvases: [
createCanvas({
id: "signals-dashboard",
displayName: "Workshop Signals",
description: "Live dashboard showing agent signals from workshop desks. Pass workshopDir to point at your workshop root.",
inputSchema: {
type: "object",
properties: {
workshopDir: { type: "string", description: "Absolute path to the workshop root (the folder containing desks/)" },
},
required: ["workshopDir"],
},
actions: [
{
name: "refresh",
description: "Force-refresh the signals dashboard and return current signal data as JSON",
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return { error: "Dashboard not open" };
const signals = await scanSignals(entry.workshopDir);
const stashed = await readStash(entry.workshopDir);
return { signals, stashed, activeCount: signals.filter(s => !stashed.some(e => e.name === s.deskName)).length };
},
},
{
name: "stash",
description: "Stash a desk (hides it for 48hrs, then it drops off). Use to pause a workstream.",
inputSchema: {
type: "object",
properties: { deskName: { type: "string", description: "Name of the desk to stash" } },
required: ["deskName"],
},
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return { error: "Dashboard not open" };
if (!isValidDeskName(ctx.input.deskName)) return { error: "Invalid desk name" };
const stash = await stashDesk(entry.workshopDir, ctx.input.deskName);
return { ok: true, stashed: stash };
},
},
{
name: "restore",
description: "Restore a stashed desk back to active.",
inputSchema: {
type: "object",
properties: { deskName: { type: "string", description: "Name of the desk to restore" } },
required: ["deskName"],
},
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return { error: "Dashboard not open" };
if (!isValidDeskName(ctx.input.deskName)) return { error: "Invalid desk name" };
const stash = await restoreDesk(entry.workshopDir, ctx.input.deskName);
return { ok: true, stashed: stash };
},
},
{
name: "open_desk",
description: "Open a desk as a new session in the GHCP app. Returns the desk path so the agent can create_session or navigate to it.",
inputSchema: {
type: "object",
properties: { deskName: { type: "string", description: "Name of the desk to open" } },
required: ["deskName"],
},
handler: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return { error: "Dashboard not open" };
if (!isValidDeskName(ctx.input.deskName)) return { error: "Invalid desk name" };
// Check both desks/ and classroom/
for (const subdir of ["desks", "classroom"]) {
const deskPath = join(entry.workshopDir, subdir, ctx.input.deskName);
try {
const s = await stat(deskPath);
if (s.isDirectory()) {
return { ok: true, deskName: ctx.input.deskName, deskPath, workshopDir: entry.workshopDir };
}
} catch {}
}
return { error: `Desk '${ctx.input.deskName}' not found` };
},
},
],
open: async (ctx) => {
const workshopDir = ctx.input?.workshopDir || process.cwd();
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(ctx.instanceId, workshopDir);
entry.workshopDir = workshopDir;
servers.set(ctx.instanceId, entry);
}
return { title: "🪨 Cairn · Signals", 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()));
}
},
}),
],
});