// 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 { existsSync } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; import { join, delimiter } from "node:path"; import { spawn } from "node:child_process"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; const servers = new Map(); const STASH_TTL_MS = 48 * 60 * 60 * 1000; // Serialize stash read-modify-write per workshop. The UI fires stash/restore // POSTs without awaiting each other, so two overlapping mutations could both // read the same array and the last write would silently drop the other. Each // workshop gets a promise chain so its mutations run one at a time. const stashLocks = new Map(); function withStashLock(workshopDir, fn) { const prev = stashLocks.get(workshopDir) || Promise.resolve(); const run = prev.then(fn, fn); stashLocks.set(workshopDir, run.then(() => {}, () => {})); return run; } // 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 !== ".."; } // Launch a desk as an in-place Copilot CLI session — the canvas counterpart to // WorkshopRoom's ConsoleLauncher. A desk is a seat that independent sessions // pick up over time, so "open" starts a fresh copilot in the desk's own folder, // oriented to read the journal and continue. This keeps every desk inside the // one workshop repo (coordinated through journals + .signals + Cairn) instead // of spinning off an isolated worktree elsewhere on disk. // // deskPath has already been confirmed to exist by the caller. We additionally // reject a path containing a double-quote before quoting it onto a command line // (a real Windows path cannot contain one), mirroring ConsoleLauncher.SafeDir, // so a planted workshop path can never break out of the -d "..." argument. function deskOrientPrompt(deskName) { return `You are sitting down at the ${deskName} desk in this workshop. ` + `Read journal.md in this folder first to pick up where the last session ` + `left off, then continue the desk's work. Write your journal before you stop.`; } // Spawn detached and resolve true only once the OS confirms the process // started ('spawn'), false on failure ('error', e.g. the binary is missing) so // the caller can fall back. A short timer guards the rare case neither fires. function trySpawn(cmd, args, opts = {}) { return new Promise((resolve) => { let settled = false; const done = (v, child) => { if (settled) return; settled = true; if (v && child) { try { child.unref(); } catch {} } resolve(v); }; try { const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...opts }); child.on("error", () => done(false)); child.on("spawn", () => done(true, child)); setTimeout(() => done(true, child), 600); } catch { resolve(false); } }); } // Resolve an executable on PATH (honoring PATHEXT on Windows), mirroring // WorkshopRoom's AgentClis.IsOnPath. Used to prefer Agency when the machine has // it installed, falling back to vanilla Copilot. function isOnPath(command) { try { const dirs = (process.env.PATH || "").split(delimiter); const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean) : []; for (const dir of dirs) { if (!dir) continue; try { if (existsSync(join(dir, command))) return true; for (const ext of exts) if (existsSync(join(dir, command + ext))) return true; } catch {} } } catch {} return false; } // The agent argv a desk opens with. Default: prefer Agency (the internal // wrapper around Copilot) when it's installed, so a desk comes up with its // MCPs/plugin already configured instead of bare GHCP; otherwise vanilla // Copilot. Agency can't take Copilot's --name (it clashes with Agency's own // --resume), matching AgentClis. Override with WORKSHOP_DESK_AGENT=copilot to // force vanilla, or =agency to insist on the wrapper. function deskAgentArgv(deskName) { const pref = (process.env.WORKSHOP_DESK_AGENT || "").trim().toLowerCase(); // An explicit override is authoritative: =agency insists on the wrapper even // when it isn't detected on PATH, and =copilot forces vanilla. Only when the // override is unset do we auto-detect and prefer Agency if it's installed. const useAgency = pref === "agency" ? true : pref === "copilot" ? false : isOnPath("agency"); return useAgency ? ["agency", "copilot"] : ["copilot", "--name", deskName]; } // A desk name flows onto a command line, and on the no-wt Windows fallback // through cmd.exe. isValidDeskName still allows shell metacharacters such as // & | > % ^, so the launcher additionally requires a conservative slug before // any shell can see the name; anything else refuses to launch and the caller // falls back to copying the path. Combined with the quote-free orientation // prompt, no untrusted text ever reaches a shell parser. function isSafeDeskNameForLaunch(name) { return isValidDeskName(name) && /^[A-Za-z0-9._-]+$/.test(name); } // POSIX single-quote a value for the macOS `do script` command line, escaping // any embedded single quotes. function shSingleQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } // AppleScript string literal: escape backslashes and double quotes. function osaStringLiteral(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } async function launchDeskConsole(deskPath, deskName) { // deskPath can't contain a double quote (a real Windows path never does and // it would break out of a quoted argument). deskName must be a plain slug so // it is safe on every command line and shell below. if (!deskPath || deskPath.includes('"')) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; if (process.platform === "win32") { // Windows Terminal is a GUI app, so it always surfaces its own visible // window even though the extension host is windowless. It is spawned via // argv (no shell), so the contents of run are passed literally. if (await trySpawn("wt.exe", ["-d", deskPath, ...run])) return true; // Fallback when wt.exe is absent: a fresh console window via `start`. // `start` re-parses its tail through cmd, so this path is only safe // because deskName is a slug and the orientation prompt carries no shell // metacharacters or quotes; nothing untrusted reaches the parser. return await trySpawn("cmd.exe", ["/c", "start", "", ...run], { cwd: deskPath }); } if (process.platform === "darwin") { // macOS: `open` can't inject a command, so drive Terminal via AppleScript // to cd into the desk and exec the agent. Each argv element is POSIX // single-quoted so the shell can't reinterpret it, and osascript itself // is spawned via argv (no shell). const line = "cd " + shSingleQuote(deskPath) + " && exec " + run.map(shSingleQuote).join(" "); const script = 'tell application "Terminal"\n' + " activate\n" + " do script " + osaStringLiteral(line) + "\n" + "end tell"; return await trySpawn("osascript", ["-e", script]); } // Linux/other: best-effort across common terminal emulators. Each is spawned // via argv (no shell) with the agent command after the emulator's exec flag, // so the desk actually comes up running its agent instead of a bare shell. const linuxTerms = [ ["x-terminal-emulator", ["-e", ...run]], ["gnome-terminal", ["--", ...run]], ["konsole", ["-e", ...run]], ["xterm", ["-e", ...run]], ]; for (const [term, args] of linuxTerms) { if (await trySpawn(term, args, { cwd: deskPath })) return true; } return false; } // 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); } // Prefer an explicit, persisted timestamp over filesystem mtime. A git // clone/checkout resets mtimes (often to a single instant), which would // otherwise scramble "latest" ordering and outcome pairing. Signals may carry // an ISO-8601 `timestamp` (or `emitted_at`); fall back to mtime when absent. function signalTime(parsed, mtimeMs) { const explicit = parsed && (parsed.timestamp || parsed.emitted_at); if (explicit) { const t = Date.parse(explicit); if (Number.isFinite(t)) return t; } return mtimeMs; } // 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) { return withStashLock(workshopDir, async () => { 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) { return withStashLock(workshopDir, async () => { 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); const emittedMs = signalTime(parsed, s.mtimeMs); allSignals.push({ parsed, mtimeMs: emittedMs, path: fp }); // Latest non-outcome signal (execution, partnership, escalation) if ((parsed.signal_type || "execution") !== "outcome" && emittedMs > latestTime) { latestTime = emittedMs; latest = { parsed, mtimeMs: emittedMs }; } } catch {} } if (!latest) { // Files exist but none parsed into a usable non-outcome signal // (malformed JSON, or outcome-only). Keep the desk visible as // "awaiting" instead of silently dropping it from the board. 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; } 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 `
"open a desk called scanning in ~/my-workshop"