// 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 { statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; import { join, delimiter, sep } from "node:path"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; 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. Before launching // we re-resolve it with realpath and require it to stay inside the workshop root // (isInsideRoot), which defeats a planted desks/foo -> /outside symlink; the // path is then only ever passed as a spawn cwd, an argv element, or a // single-quoted literal inside the macOS Terminal command — never concatenated // raw onto a command line — so no character filtering of the path is required. 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. Node guarantees exactly one of those events fires // for a spawn attempt, so we resolve solely from them — no timeout that could // report an unconfirmed launch as success and skip the clipboard fallback. 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)); } 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. // A PATH hit only counts if it resolves to a real, runnable file. existsSync // alone would treat a directory or a non-executable file named `agency` as a // match, so auto-detection would pick the wrapper and the terminal would then // fail to run it with no fallback. function isExecutableFile(p) { try { if (!statSync(p).isFile()) return false; if (process.platform !== "win32") accessSync(p, fsConstants.X_OK); return true; } catch { return false; } } 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; // On Windows only a PATHEXT match is runnable; on POSIX check the bare // name, and isExecutableFile confirms the execute bit either way. if (exts.length) { for (const ext of exts) if (isExecutableFile(join(dir, command + ext))) return true; } else if (isExecutableFile(join(dir, command))) { return true; } } } 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, double quotes, and line breaks // (a raw CR/LF in a path would otherwise terminate the literal and fail to // compile, while osascript still spawns and trySpawn would report success). function osaStringLiteral(s) { return '"' + String(s) .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\r/g, "\\r") .replace(/\n/g, "\\n") + '"'; } // Resolve symlinks on both sides and confirm the target is the workshop root // itself or lives beneath it. The callers locate a desk with stat(), which // follows symlinks, so a committed desks/foo -> /outside symlink would otherwise // launch the agent with an external working directory, breaking the inside-repo // guarantee. function isInsideRoot(root, target) { try { const r = realpathSync(root); const t = realpathSync(target); if (t === r) return true; // A filesystem root ("/" or "C:\") already ends with the separator, so // don't append a second one or every desk below it would fail the prefix // test and opening would always fall back. const prefix = r.endsWith(sep) ? r : r + sep; return t.startsWith(prefix); } catch { return false; } } async function launchDeskConsole(deskPath, deskName, workshopDir) { // deskName must be a plain slug so it is safe on every command line and shell // below, and the resolved desk must still live inside the workshop root // (which defeats a symlinked desk that escapes the repo). deskPath itself is // only ever passed as an argv element / spawn cwd (Windows via -d plus cwd, // Linux via cwd) or as a single-quoted literal inside the macOS Terminal // command, so an empty-path guard is all that is needed — a quote in the path // can't break out of any of those. if (!deskPath) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; if (!isInsideRoot(workshopDir, deskPath)) return false; const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; if (process.platform === "win32") { // Run the agent through cmd.exe (/k) so PATHEXT is applied: globally // installed CLIs like `copilot`/`agency` are usually .cmd shims that // Windows Terminal or a bare CreateProcess would fail to launch (they // expect a literal executable, not a PATHEXT name). Windows Terminal is a // GUI app, so it still surfaces its own window from the windowless host. // Each element of run is its own argv token — deskName is a slug and the // orientation prompt has no cmd metacharacters — and the desk path is // passed via -d/cwd, so nothing untrusted is reparsed by a shell. if (await trySpawn("wt.exe", ["-d", deskPath, "cmd", "/k", ...run])) return true; // Fallback when wt.exe is absent: a fresh console window via `start`, // still through cmd /k for the same PATHEXT resolution. return await trySpawn("cmd.exe", ["/c", "start", "", "cmd", "/k", ...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"; } // Pin the Host header to the exact loopback authority we bound. A DNS-rebinding // page reaches us under its own hostname (Host: attacker.example:), so an // exact match against 127.0.0.1: refuses those requests before any state // change — Origin/Host equality alone doesn't, since the attacker controls both. function isCanonicalHost(req, canonicalHost) { return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase(); } // Capability check for the per-server token minted at startup and embedded in // the page we serve. Only the loopback document we rendered knows it, so a blind // cross-origin/rebinding caller can't forge a mutating request even if it // reached the socket. function hasCapabilityToken(req, token) { const header = req.headers["x-workshop-token"]; const provided = Array.isArray(header) ? header[0] : header; return typeof provided === "string" && provided.length > 0 && provided === token; } // --- 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 `
${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 = avgGap !== null ? `🔍 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 = ``; 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, capabilityToken) { 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) { // Minted once per server: embedded in the page we render and required back on // every mutating request. canonicalHost is filled in after listen() so the // Host pin knows the exact port we bound. const capabilityToken = randomBytes(32).toString("base64url"); let canonicalHost = null; const server = createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host}`); // Layered guard for the state-changing /api/* routes (cf. // connector-namespaces/server.mjs): the canonical-Host pin defeats DNS // rebinding, the CSRF check blocks cross-site browser POSTs, and the // capability token proves the caller actually loaded our page. if (req.method === "POST" && url.pathname.startsWith("/api/")) { if (!isCanonicalHost(req, canonicalHost) || isCrossSiteRequest(req)) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" })); return; } if (!hasCapabilityToken(req, capabilityToken)) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: "missing_capability_token" })); 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()) { const launched = await launchDeskConsole(deskPath, deskName, workshopDir); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, deskName, deskPath, launched })); 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, capabilityToken)); } 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, reject) => { const onError = (err) => { server.removeListener("listening", onListening); reject(err); }; const onListening = () => { server.removeListener("error", onError); resolve(); }; server.once("error", onError); server.once("listening", onListening); server.listen(0, "127.0.0.1"); }); const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; canonicalHost = `127.0.0.1:${port}`; 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: "get_desk_path", description: "Resolve a desk name to its filesystem path. Does not open a session — returns the path so the caller 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` }; }, }, { name: "open_desk", description: "Open a desk as an in-place Copilot CLI session: launches a terminal in the desk's folder (inside the workshop repo) running copilot, oriented to read the desk journal and continue. This is the Model A 'sit down at the desk' — no new worktree, no session spun off elsewhere. Returns the desk path and whether a terminal was launched.", 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" }; for (const subdir of ["desks", "classroom"]) { const deskPath = join(entry.workshopDir, subdir, ctx.input.deskName); try { const s = await stat(deskPath); if (s.isDirectory()) { const launched = await launchDeskConsole(deskPath, ctx.input.deskName, entry.workshopDir); return { ok: true, deskName: ctx.input.deskName, deskPath, launched, 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())); } }, }), ], });