// 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();
const useAgency = pref === "copilot" ? false : isOnPath("agency");
return useAgency ? ["agency", "copilot"] : ["copilot", "--name", deskName];
}
async function launchDeskConsole(deskPath, deskName) {
if (!deskPath || deskPath.includes('"')) return false;
const orient = deskOrientPrompt(deskName);
const run = [...deskAgentArgv(deskName), "-i", orient]; // agent + orientation
if (process.platform === "win32") {
// Windows Terminal is a GUI app, so it always surfaces its own visible
// window even though the extension host itself is windowless — opened in
// the desk folder, running the desk's agent oriented to the desk.
if (await trySpawn("wt.exe", ["-d", deskPath, ...run])) return true;
// Fallback when wt.exe is absent: a fresh console window via `start`,
// with the working directory set to the desk folder.
return await trySpawn("cmd.exe", ["/c", "start", "", ...run], { cwd: deskPath });
}
if (process.platform === "darwin") {
// macOS: open Terminal.app in the desk folder. `open` can't inject the
// agent command, so it drops the user into the desk to run it.
return await trySpawn("open", ["-a", "Terminal", deskPath]);
}
// Linux/other: best-effort across common terminal emulators, cwd = desk.
for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]) {
if (await trySpawn(term, [], { 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 `