// 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; // --- 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" && Math.abs(s.mtimeMs - latestTime) < 3600000) .sort((a, b) => b.mtimeMs - a.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 = sig.self_assessment.confidence || 0; const outcomeRating = outcome.quality_rating || 0; 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: typeof intentRaw === "number" ? intentRaw : 0, confidence: sig.self_assessment?.confidence || 0, accuracy: sig.self_assessment?.accuracy || 0, completeness: sig.self_assessment?.completeness || 0, 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: sig.usage?.tokens_in || 0, tokensOut: sig.usage?.tokens_out || 0, model: sig.usage?.model || null, // Outcome signal fields outcomeRating: outcome?.quality_rating || null, outcomeEffort: outcome?.effort_to_merge || null, outcomeIssues: 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 s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function truncate(s, len) { return s.length > len ? s.slice(0, len) + "…" : s; } 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 `
${label} ${value}/5
`; } 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
${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) => { const url = new URL(req.url, `http://${req.headers.host}`); if (req.method === "POST" && url.pathname.startsWith("/api/stash/")) { const deskName = decodeURIComponent(url.pathname.split("/api/stash/")[1]); 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]); 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]); 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)); }); 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.length - stashed.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" }; 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" }; 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" }; // 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())); } }, }), ], });