diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index 2749b46e..d1a10785 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -1024,6 +1024,12 @@
"description": "Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications.",
"version": "1.0.0"
},
+ {
+ "name": "signals-dashboard",
+ "source": "extensions/signals-dashboard",
+ "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.",
+ "version": "0.1.0"
+ },
{
"name": "site-studio",
"source": "extensions/site-studio",
diff --git a/extensions/signals-dashboard/.github/plugin/plugin.json b/extensions/signals-dashboard/.github/plugin/plugin.json
new file mode 100644
index 00000000..799ed39e
--- /dev/null
+++ b/extensions/signals-dashboard/.github/plugin/plugin.json
@@ -0,0 +1,17 @@
+{
+ "name": "signals-dashboard",
+ "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.",
+ "version": "0.1.0",
+ "author": {
+ "name": "jennyf19",
+ "url": "https://github.com/jennyf19"
+ },
+ "keywords": [
+ "agent-signals",
+ "dashboard",
+ "multi-agent",
+ "coordination",
+ "canvas"
+ ],
+ "extensions": "."
+}
diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs
new file mode 100644
index 00000000..39a593c0
--- /dev/null
+++ b/extensions/signals-dashboard/extension.mjs
@@ -0,0 +1,711 @@
+// 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()));
+ }
+ },
+ }),
+ ],
+});
diff --git a/plugins/the-workshop/.github/plugin/plugin.json b/plugins/the-workshop/.github/plugin/plugin.json
index a43b4451..fbe149f0 100644
--- a/plugins/the-workshop/.github/plugin/plugin.json
+++ b/plugins/the-workshop/.github/plugin/plugin.json
@@ -24,5 +24,10 @@
"./skills/desk-open/",
"./skills/signal-write/",
"./skills/workshop-create/"
- ]
+ ],
+ "x-awesome-copilot": {
+ "extensions": [
+ "./extensions/signals-dashboard/"
+ ]
+ }
}