From aeb70304ee01362f3ec72755cf06b09842e2fe35 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 5 Aug 2026 03:59:30 +0000
Subject: [PATCH] chore: publish from main
---
.github/plugin/marketplace.json | 4 +-
.../.github/plugin/plugin.json | 4 +-
.../signals-dashboard/extension.mjs | 354 +++++++++++++++---
.../signals-dashboard/launch-profile.mjs | 88 +++++
.../signals-dashboard/launch-profile.test.mjs | 142 +++++++
.../extensions/signals-dashboard/package.json | 7 +-
plugins/the-workshop/README.md | 15 +-
.../.github/plugin/plugin.json | 4 +-
.../signals-dashboard/extension.mjs | 354 +++++++++++++++---
.../signals-dashboard/launch-profile.mjs | 88 +++++
.../signals-dashboard/launch-profile.test.mjs | 142 +++++++
.../extensions/signals-dashboard/package.json | 7 +-
12 files changed, 1079 insertions(+), 130 deletions(-)
create mode 100644 extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs
create mode 100644 extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs
create mode 100644 plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs
create mode 100644 plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs
diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index bbf95631..ba86b835 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -1146,8 +1146,8 @@
{
"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"
+ "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.",
+ "version": "0.2.0"
},
{
"name": "site-studio",
diff --git a/extensions/signals-dashboard/.github/plugin/plugin.json b/extensions/signals-dashboard/.github/plugin/plugin.json
index dccc9634..5b3ad3ed 100644
--- a/extensions/signals-dashboard/.github/plugin/plugin.json
+++ b/extensions/signals-dashboard/.github/plugin/plugin.json
@@ -1,7 +1,7 @@
{
"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",
+ "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.",
+ "version": "0.2.0",
"author": {
"name": "jennyf19",
"url": "https://github.com/jennyf19"
diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs
index f45b6431..3309f0f5 100644
--- a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs
+++ b/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs
@@ -6,13 +6,26 @@
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 { join, delimiter, isAbsolute, sep } from "node:path";
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+import {
+ buildDeskAgentArgv,
+ isDeskProfile,
+ isSafeWindowsCmdShim,
+ isWindowsAppExecutionAlias,
+ normalizeDeskProfile,
+ parsePluginMcpNames,
+ quoteWindowsCmdArgument,
+} from "./launch-profile.mjs";
const servers = new Map();
const STASH_TTL_MS = 48 * 60 * 60 * 1000;
+const MCP_DISCOVERY_TTL_MS = 5 * 60 * 1000;
+const MCP_DISCOVERY_MAX_BYTES = 1024 * 1024;
+const DEFAULT_DESK_PROFILE = normalizeDeskProfile(process.env.WORKSHOP_DESK_PROFILE);
+const mcpDiscoveryCache = new Map();
// Serialize stash read-modify-write per workshop. The UI fires stash/restore
// POSTs without awaiting each other, so two overlapping mutations could both
@@ -83,48 +96,217 @@ function trySpawn(cmd, args, opts = {}) {
// 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; }
+ return probeExecutableFile(p).ok;
}
-function isOnPath(command) {
+function probeExecutableFile(p) {
+ try {
+ if (!statSync(p).isFile()) return { ok: false, errorCode: null };
+ if (process.platform !== "win32") accessSync(p, fsConstants.X_OK);
+ return { ok: true, errorCode: null };
+ } catch (error) {
+ return { ok: false, errorCode: error?.code || null };
+ }
+}
+
+function resolveOnPath(command, { directOnly = false, excludedRoot = null } = {}) {
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;
+ for (const rawDir of dirs) {
+ const dir = rawDir.replace(/^"(.*)"$/, "$1");
+ if (!dir || !isAbsolute(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;
+ for (const ext of exts) {
+ if (directOnly && ![".EXE", ".COM"].includes(ext.toUpperCase())) continue;
+ const candidate = join(dir, command + ext);
+ const probe = probeExecutableFile(candidate);
+ const appAlias = directOnly && probe.errorCode === "EACCES" &&
+ isWindowsAppExecutionAlias(candidate, process.env.LOCALAPPDATA);
+ if (!appAlias && !probe.ok) continue;
+ const resolved = appAlias ? candidate : realpathSync(candidate);
+ if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue;
+ return resolved;
+ }
+ } else {
+ const candidate = join(dir, command);
+ if (!isExecutableFile(candidate)) continue;
+ const resolved = realpathSync(candidate);
+ if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue;
+ return resolved;
}
}
} catch {}
- return false;
+ return null;
}
-// 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) {
+function resolveDeskAgent(workshopDir) {
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];
+ const agencyCommand = resolveOnPath("agency", { excludedRoot: workshopDir });
+ const copilotCommand = resolveOnPath("copilot", { excludedRoot: workshopDir });
+ // Explicit overrides are authoritative and fail closed when unavailable.
+ if (pref === "agency") {
+ return agencyCommand
+ ? { useAgency: true, agencyCommand, copilotCommand }
+ : null;
+ }
+ if (pref === "copilot") {
+ return copilotCommand
+ ? { useAgency: false, agencyCommand, copilotCommand }
+ : null;
+ }
+ if (agencyCommand) return { useAgency: true, agencyCommand, copilotCommand };
+ if (copilotCommand) return { useAgency: false, agencyCommand, copilotCommand };
+ return null;
+}
+
+function resolveSystem32Executable(name) {
+ if (process.platform !== "win32") return null;
+ const root = process.env.SystemRoot || process.env.WINDIR;
+ if (!root || !isAbsolute(root)) return null;
+ try {
+ const candidate = join(root, "System32", name);
+ return isExecutableFile(candidate) ? realpathSync(candidate) : null;
+ } catch {
+ return null;
+ }
+}
+
+function terminateProcessTree(child) {
+ if (!child || child.exitCode !== null) return;
+ if (process.platform === "win32" && child.pid) {
+ const taskkill = resolveSystem32Executable("taskkill.exe");
+ if (!taskkill) return;
+ try {
+ const killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
+ windowsHide: true,
+ stdio: "ignore",
+ });
+ killer.unref();
+ } catch {}
+ return;
+ }
+ try {
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
+ else child.kill("SIGKILL");
+ } catch {
+ try { child.kill("SIGKILL"); } catch {}
+ }
+}
+
+// Capture the underlying Copilot plugin inventory directly. Agency repo mode
+// separately suppresses its own default/config plugins, so discovery does not
+// need a wrapper process that can leave inherited pipes or descendants behind.
+function capturePluginMcpJson(workshopDir, agent) {
+ return new Promise((resolve) => {
+ const command = agent.copilotCommand;
+ if (!command) {
+ resolve(null);
+ return;
+ }
+ const args = ["plugins", "list", "--kind", "mcp", "--scope", "plugin", "--json"];
+ const shim = process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
+ if (shim && !isSafeWindowsCmdShim(command)) {
+ resolve(null);
+ return;
+ }
+ const spawnCommand = shim ? resolveSystem32Executable("cmd.exe") : command;
+ if (!spawnCommand) {
+ resolve(null);
+ return;
+ }
+ const spawnArgs = shim
+ ? ["/d", "/s", "/c",
+ `"${[command, ...args].map(quoteWindowsCmdArgument).join(" ")}"`]
+ : args;
+
+ let settled = false;
+ let stdout = "";
+ const done = (value, child, timer, terminate = false) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (terminate) {
+ try { child.stdout.destroy(); } catch {}
+ terminateProcessTree(child);
+ }
+ resolve(value);
+ };
+
+ let child;
+ try {
+ child = spawn(spawnCommand, spawnArgs, {
+ cwd: workshopDir,
+ detached: process.platform !== "win32",
+ windowsHide: true,
+ windowsVerbatimArguments: shim,
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ } catch {
+ resolve(null);
+ return;
+ }
+
+ const timer = setTimeout(() => done(null, child, timer, true), 30000);
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ if (settled) return;
+ stdout += chunk;
+ if (Buffer.byteLength(stdout, "utf8") > MCP_DISCOVERY_MAX_BYTES) {
+ done(null, child, timer, true);
+ return;
+ }
+ if (parsePluginMcpNames(stdout) !== null) done(stdout, child, timer, true);
+ });
+ child.on("error", () => done(null, child, timer));
+ child.on("close", (code) => done(code === 0 ? stdout : null, child, timer));
+ });
+}
+
+async function discoverPluginMcpNames(workshopDir, agent) {
+ const cacheKey = `${workshopDir}\0${agent.copilotCommand || "missing"}`;
+ const cached = mcpDiscoveryCache.get(cacheKey);
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
+
+ const output = await capturePluginMcpJson(workshopDir, agent);
+ const names = output === null ? null : parsePluginMcpNames(output);
+ const value = names === null
+ ? { ok: false, names: [] }
+ : { ok: true, names };
+ mcpDiscoveryCache.set(cacheKey, {
+ expiresAt: Date.now() + MCP_DISCOVERY_TTL_MS,
+ value,
+ });
+ return value;
+}
+
+// Preserve the existing Agency-aware launch and layer the repo profile on top.
+// Repo mode suppresses ambient plugin MCPs; connected mode keeps today's tool
+// surface. Plugin discovery fails open, while Agency repo mode still suppresses
+// Agency's own default MCPs.
+async function deskAgentArgv(deskName, workshopDir, profile) {
+ const resolved = resolveDeskAgent(workshopDir);
+ if (!resolved) return null;
+ const { useAgency, agencyCommand, copilotCommand } = resolved;
+ if (useAgency && !agencyCommand) return null;
+ if (!useAgency && !copilotCommand) return null;
+ const discovery = profile === "repo"
+ ? await discoverPluginMcpNames(workshopDir, resolved)
+ : { ok: true, names: [] };
+ return buildDeskAgentArgv({
+ deskName,
+ workshopDir,
+ useAgency,
+ agencyCommand,
+ copilotCommand,
+ profile,
+ pluginMcpNames: discovery.names,
+ discoverySucceeded: discovery.ok,
+ });
}
// A desk name flows onto a command line, and on the no-wt Windows fallback
@@ -172,7 +354,7 @@ function isInsideRoot(root, target) {
} catch { return false; }
}
-async function launchDeskConsole(deskPath, deskName, workshopDir) {
+async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFAULT_DESK_PROFILE) {
// 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
@@ -183,22 +365,30 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
if (!deskPath) return false;
if (!isSafeDeskNameForLaunch(deskName)) return false;
if (!isInsideRoot(workshopDir, deskPath)) return false;
- const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)];
+ const agent = await deskAgentArgv(deskName, workshopDir, profile);
+ if (!agent) return false;
+ const run = [...agent, "-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;
+ const wt = resolveOnPath("wt", { directOnly: true, excludedRoot: workshopDir });
+ const cmd = resolveSystem32Executable("cmd.exe");
+ const direct = /\.(exe|com)$/i.test(run[0]);
+ if (direct && wt && await trySpawn(wt, ["-d", deskPath, ...run])) return true;
+
+ // Older installs can expose .cmd/.bat shims. Only use cmd.exe when every
+ // argument is free of cmd metacharacters; otherwise fail closed and let
+ // the UI copy the desk path rather than reparse an unsafe workshop path.
+ const cmdSafe = run.every((arg) => !/[&|<>^%!()\r\n]/.test(arg));
+ if (cmdSafe && wt && cmd &&
+ await trySpawn(wt, ["-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 });
+ // still through cmd /k only when the arguments are safe for reparsing.
+ return cmdSafe && cmd
+ ? await trySpawn(cmd, ["/c", "start", "", cmd, "/k", ...run], { cwd: deskPath })
+ : false;
}
if (process.platform === "darwin") {
+ const osascript = "/usr/bin/osascript";
+ if (!isExecutableFile(osascript)) return false;
// 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
@@ -209,7 +399,7 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
" activate\n" +
" do script " + osaStringLiteral(line) + "\n" +
"end tell";
- return await trySpawn("osascript", ["-e", script]);
+ 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,
@@ -221,7 +411,8 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
["xterm", ["-e", ...run]],
];
for (const [term, args] of linuxTerms) {
- if (await trySpawn(term, args, { cwd: deskPath })) return true;
+ const executable = resolveOnPath(term, { excludedRoot: workshopDir });
+ if (executable && await trySpawn(executable, args, { cwd: deskPath })) return true;
}
return false;
}
@@ -612,11 +803,20 @@ function renderSignalCard(sig) {
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 = ``;
+ title="Open this desk with the ${esc(DEFAULT_DESK_PROFILE)} tool profile">open`;
+ const connectedBtn = DEFAULT_DESK_PROFILE === "connected" ? "" : `
+ `;
let escalationBlock = "";
if (isEscalation && sig.escalationReason) {
@@ -719,6 +919,7 @@ function renderSignalCard(sig) {
${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""}
${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""}
${openBtn}
+ ${connectedBtn}
${stashBtn}
@@ -850,14 +1051,16 @@ function renderDashboard(signals, stashed, capabilityToken) {
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
- async function openDesk(name) {
- const res = await fetch('/api/open/' + encodeURIComponent(name), POST_OPTS);
+ async function openDesk(name, profile) {
+ const selectedProfile = profile || ${JSON.stringify(DEFAULT_DESK_PROFILE)};
+ const res = await fetch('/api/open/' + encodeURIComponent(name) +
+ '?profile=' + encodeURIComponent(selectedProfile), POST_OPTS);
const data = await res.json();
if (data.ok) {
const path = data.deskPath || name;
if (data.launched) {
// A successful open shouldn't hijack the user's clipboard.
- showToast('opening ' + name + ' desk…', path);
+ showToast('opening ' + name + ' desk (' + selectedProfile + ')…', path);
} else {
// No terminal launched from here, so copy the path as the
// fallback handle, but only claim the copy when it actually
@@ -876,9 +1079,10 @@ function renderDashboard(signals, stashed, capabilityToken) {
const name = btn.getAttribute('data-desk');
if (!name) return;
const act = btn.getAttribute('data-act');
+ const profile = btn.getAttribute('data-profile');
if (act === 'stash') stashDesk(name);
else if (act === 'restore') restoreDesk(name);
- else if (act === 'open') openDesk(name);
+ else if (act === 'open') openDesk(name, profile);
});
async function refresh() {
try {
@@ -894,15 +1098,22 @@ function renderDashboard(signals, stashed, capabilityToken) {
const active = document.activeElement;
let focusKey = null;
if (active && active.matches && active.matches('button[data-act]')) {
- focusKey = active.getAttribute('data-act') + '|' + active.getAttribute('data-desk');
+ focusKey = JSON.stringify([
+ active.getAttribute('data-act'),
+ active.getAttribute('data-desk'),
+ active.getAttribute('data-profile') || '',
+ ]);
}
content.innerHTML = newContent.innerHTML;
if (focusKey) {
- const bar = focusKey.indexOf('|');
- const act = focusKey.slice(0, bar);
- const desk = focusKey.slice(bar + 1);
+ const [act, desk, profile] = JSON.parse(focusKey);
const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk;
- const target = content.querySelector('button[data-act="' + act + '"][data-desk="' + escDesk + '"]');
+ const profileSelector = profile
+ ? '[data-profile="' + profile + '"]'
+ : ':not([data-profile])';
+ const target = content.querySelector(
+ 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' +
+ profileSelector);
if (target) target.focus();
}
}
@@ -971,19 +1182,26 @@ async function startServer(instanceId, workshopDir) {
}
if (req.method === "POST" && url.pathname.startsWith("/api/open/")) {
const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]);
+ const profileInput = url.searchParams.get("profile") || DEFAULT_DESK_PROFILE;
if (!isValidDeskName(deskName)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid desk name" }));
return;
}
+ if (!isDeskProfile(profileInput)) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "Invalid desk profile" }));
+ return;
+ }
+ const profile = normalizeDeskProfile(profileInput);
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);
+ const launched = await launchDeskConsole(deskPath, deskName, workshopDir, profile);
res.writeHead(200, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ ok: true, deskName, deskPath, launched }));
+ res.end(JSON.stringify({ ok: true, deskName, deskPath, launched, profile }));
return;
}
} catch {}
@@ -1109,23 +1327,41 @@ const session = await joinSession({
},
{
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.",
+ description: "Open a desk as an in-place Copilot CLI session. Repo profile suppresses ambient plugin MCPs; connected keeps every configured tool. Returns the desk path, profile, and whether a terminal was launched.",
inputSchema: {
type: "object",
- properties: { deskName: { type: "string", description: "Name of the desk to open" } },
+ properties: {
+ deskName: { type: "string", description: "Name of the desk to open" },
+ profile: {
+ type: "string",
+ enum: ["repo", "connected"],
+ description: `Tool profile. Defaults to ${DEFAULT_DESK_PROFILE}.`,
+ },
+ },
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 profileInput = ctx.input.profile || DEFAULT_DESK_PROFILE;
+ if (!isDeskProfile(profileInput)) return { error: "Invalid desk profile" };
+ const profile = normalizeDeskProfile(profileInput);
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 };
+ const launched = await launchDeskConsole(
+ deskPath, ctx.input.deskName, entry.workshopDir, profile);
+ return {
+ ok: true,
+ deskName: ctx.input.deskName,
+ deskPath,
+ launched,
+ workshopDir: entry.workshopDir,
+ profile,
+ };
}
} catch {}
}
diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs
new file mode 100644
index 00000000..4f06c6f2
--- /dev/null
+++ b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs
@@ -0,0 +1,88 @@
+const PROFILES = new Set(["repo", "connected"]);
+const SAFE_MCP_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
+
+export function isDeskProfile(value) {
+ return typeof value === "string" && PROFILES.has(value.toLowerCase());
+}
+
+export function normalizeDeskProfile(value, fallback = "repo") {
+ return isDeskProfile(value) ? value.toLowerCase() : fallback;
+}
+
+export function isWindowsAppExecutionAlias(candidate, localAppData) {
+ if (typeof candidate !== "string" || typeof localAppData !== "string") return false;
+ const normalized = candidate.replaceAll("/", "\\").toLowerCase();
+ const root = `${localAppData.replaceAll("/", "\\").replace(/\\+$/, "")}` +
+ "\\microsoft\\windowsapps\\";
+ return normalized.startsWith(root.toLowerCase()) && normalized.endsWith(".exe");
+}
+
+export function quoteWindowsCmdArgument(value) {
+ return `"${String(value).replaceAll('"', '""')}"`;
+}
+
+export function isSafeWindowsCmdShim(value) {
+ return typeof value === "string" && !/[%\r\n]/.test(value);
+}
+
+export function parsePluginMcpNames(text) {
+ let parsed;
+ try { parsed = JSON.parse(text); }
+ catch {
+ // Agency may prefix its pass-through command with human-readable startup
+ // lines. Its underlying Copilot JSON is the final object in stdout.
+ let start = text.lastIndexOf("{");
+ while (start >= 0) {
+ try {
+ parsed = JSON.parse(text.slice(start));
+ break;
+ } catch {
+ start = text.lastIndexOf("{", start - 1);
+ }
+ }
+ if (start < 0) return null;
+ }
+
+ if (!Array.isArray(parsed?.plugins)) return null;
+
+ const names = [];
+ const seen = new Set();
+ for (const entry of parsed.plugins) {
+ if (entry?.kind !== "mcp" || entry.enabled === false) continue;
+ if (entry.scope !== "plugin" && entry.source !== "plugin") continue;
+ if (typeof entry.name !== "string" || !SAFE_MCP_NAME.test(entry.name)) continue;
+ if (!seen.has(entry.name)) {
+ seen.add(entry.name);
+ names.push(entry.name);
+ }
+ }
+ return names;
+}
+
+export function buildDeskAgentArgv({
+ deskName,
+ workshopDir,
+ useAgency,
+ agencyCommand = "agency",
+ copilotCommand = "copilot",
+ profile = "repo",
+ pluginMcpNames = [],
+ discoverySucceeded = true,
+}) {
+ const argv = useAgency
+ ? [agencyCommand, "copilot"]
+ : [copilotCommand, "--name", deskName];
+
+ if (profile === "repo") {
+ if (useAgency) argv.push("--no-default-mcps");
+ }
+
+ if (profile === "repo" && discoverySucceeded) {
+ for (const name of pluginMcpNames) {
+ if (SAFE_MCP_NAME.test(name)) argv.push("--disable-mcp-server", name);
+ }
+ }
+
+ argv.push("--add-dir", workshopDir);
+ return argv;
+}
diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs
new file mode 100644
index 00000000..bde1f730
--- /dev/null
+++ b/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs
@@ -0,0 +1,142 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ buildDeskAgentArgv,
+ isDeskProfile,
+ isSafeWindowsCmdShim,
+ isWindowsAppExecutionAlias,
+ normalizeDeskProfile,
+ parsePluginMcpNames,
+ quoteWindowsCmdArgument,
+} from "./launch-profile.mjs";
+
+test("normalizes supported profiles and defaults unknown values to repo", () => {
+ assert.equal(isDeskProfile("repo"), true);
+ assert.equal(isDeskProfile("CONNECTED"), true);
+ assert.equal(isDeskProfile("other"), false);
+ assert.equal(normalizeDeskProfile("CONNECTED"), "connected");
+ assert.equal(normalizeDeskProfile("other"), "repo");
+});
+
+test("recognizes Windows App Execution Alias paths without trusting repository executables", () => {
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe",
+ "C:\\Users\\person\\AppData\\Local"), true);
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\repo\\wt.exe",
+ "C:\\Users\\person\\AppData\\Local"), false);
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.cmd",
+ "C:\\Users\\person\\AppData\\Local"), false);
+});
+
+test("quotes trusted cmd shim arguments and rejects percent-bearing paths", () => {
+ assert.equal(
+ quoteWindowsCmdArgument("C:\\Program Files\\Agency\\agency.cmd"),
+ "\"C:\\Program Files\\Agency\\agency.cmd\"");
+ assert.equal(quoteWindowsCmdArgument("--scope"), "\"--scope\"");
+ assert.equal(isSafeWindowsCmdShim("C:\\Program Files\\Agency\\agency.cmd"), true);
+ assert.equal(isSafeWindowsCmdShim("C:\\Users\\%USERNAME%\\agency.cmd"), false);
+});
+
+test("executes a Windows cmd shim with safe quoting", {
+ skip: process.platform !== "win32",
+}, () => {
+ const root = mkdtempSync(join(tmpdir(), "workshop-profile-"));
+ const shimDir = join(root, "Shim Name");
+ mkdirSync(shimDir);
+ const shim = join(shimDir, "copilot.cmd");
+ writeFileSync(shim, "@echo off\r\necho {\"plugins\":[]}\r\n");
+
+ const cmd = join(process.env.SystemRoot, "System32", "cmd.exe");
+ const commandLine = `"${[shim, "plugins", "list"]
+ .map(quoteWindowsCmdArgument)
+ .join(" ")}"`;
+ const result = spawnSync(cmd, ["/d", "/s", "/c", commandLine], {
+ encoding: "utf8",
+ windowsHide: true,
+ windowsVerbatimArguments: true,
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /\{"plugins":\[\]\}/);
+});
+
+test("extracts enabled plugin-scoped MCP names and rejects unsafe names", () => {
+ const names = parsePluginMcpNames(JSON.stringify({
+ plugins: [
+ { kind: "mcp", name: "teams", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "repo-mcp", source: "plugin", enabled: true },
+ { kind: "mcp", name: "teams", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "disabled", scope: "plugin", enabled: false },
+ { kind: "mcp", name: "workspace", scope: "repository", enabled: true },
+ { kind: "skill", name: "not-an-mcp", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "bad;name", scope: "plugin", enabled: true },
+ ],
+ }));
+
+ assert.deepEqual(names, ["teams", "repo-mcp"]);
+ assert.deepEqual(parsePluginMcpNames(`Agency startup\n${JSON.stringify({
+ plugins: [{ kind: "mcp", name: "ado", scope: "plugin", enabled: true }],
+ })}`), ["ado"]);
+ assert.equal(parsePluginMcpNames("not json"), null);
+ assert.equal(parsePluginMcpNames("{}"), null);
+});
+
+test("builds an Agency repo profile on top of the existing wrapper", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "C:\\workshop",
+ useAgency: true,
+ agencyCommand: "C:\\tools\\agency.exe",
+ profile: "repo",
+ pluginMcpNames: ["teams", "ado"],
+ }), [
+ "C:\\tools\\agency.exe", "copilot", "--no-default-mcps",
+ "--disable-mcp-server", "teams",
+ "--disable-mcp-server", "ado",
+ "--add-dir", "C:\\workshop",
+ ]);
+});
+
+test("builds a plain Copilot repo profile without Agency-only flags", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: false,
+ copilotCommand: "/usr/local/bin/copilot",
+ profile: "repo",
+ pluginMcpNames: ["calendar"],
+ }), [
+ "/usr/local/bin/copilot", "--name", "cost-desk",
+ "--disable-mcp-server", "calendar",
+ "--add-dir", "/workshop",
+ ]);
+});
+
+test("connected preserves tools while Agency discovery failure still removes defaults", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: true,
+ profile: "connected",
+ pluginMcpNames: ["teams"],
+ }), [
+ "agency", "copilot", "--add-dir", "/workshop",
+ ]);
+
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: true,
+ profile: "repo",
+ pluginMcpNames: [],
+ discoverySucceeded: false,
+ }), [
+ "agency", "copilot", "--no-default-mcps", "--add-dir", "/workshop",
+ ]);
+});
diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/package.json b/extensions/signals-dashboard/extensions/signals-dashboard/package.json
index b1625c1a..6165e97c 100644
--- a/extensions/signals-dashboard/extensions/signals-dashboard/package.json
+++ b/extensions/signals-dashboard/extensions/signals-dashboard/package.json
@@ -1,12 +1,15 @@
{
"name": "signals-dashboard",
- "version": "0.1.0",
+ "version": "0.2.0",
"type": "module",
"main": "extension.mjs",
+ "scripts": {
+ "test": "node --test launch-profile.test.mjs"
+ },
"dependencies": {
"@github/copilot-sdk": "latest"
},
- "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.",
+ "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.",
"keywords": [
"agent-signals",
"dashboard",
diff --git a/plugins/the-workshop/README.md b/plugins/the-workshop/README.md
index 16c88a88..a0aecb3f 100644
--- a/plugins/the-workshop/README.md
+++ b/plugins/the-workshop/README.md
@@ -37,8 +37,19 @@ A **desk** isn't a sub-agent — it's a peer with a history. Sub-agents inherit
The Workshop's live view is a **canvas extension** (🪨 Cairn) — `signals-dashboard` — that shows the pulse of every desk (score bars, patterns, escalations), auto-refreshing in the GitHub Copilot app.
-Each desk card also has an **open** button that launches a Copilot CLI right in
-that desk's folder, so you can sit down at a desk straight from the board.
+Each desk card has two launch choices:
+
+- **open** — the cost-aware repo profile. The desk keeps access to the Workshop
+ root for cross-desk journals and artifacts while ambient plugin MCPs are
+ suppressed.
+- **connected** — the full configured tool surface for work that needs external
+ systems such as issue trackers, mail, or service APIs.
+
+Set `WORKSHOP_DESK_PROFILE=connected` to make the main **open** button use the
+full tool surface; in that mode the separate **connected** button is omitted.
+
+Both launch a CLI in the desk's folder. If Agency is installed, Cairn keeps
+using the existing Agency wrapper; otherwise it launches plain Copilot CLI.
It ships as a separate extension. Install it alongside the plugin to get the live canvas:
diff --git a/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json b/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json
index 7bfd8dbc..7e4e6cd4 100644
--- a/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json
+++ b/plugins/the-workshop/extensions/signals-dashboard/.github/plugin/plugin.json
@@ -1,7 +1,7 @@
{
"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",
+ "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.",
+ "version": "0.2.0",
"author": {
"name": "jennyf19",
"url": "https://github.com/jennyf19"
diff --git a/plugins/the-workshop/extensions/signals-dashboard/extension.mjs b/plugins/the-workshop/extensions/signals-dashboard/extension.mjs
index f45b6431..3309f0f5 100644
--- a/plugins/the-workshop/extensions/signals-dashboard/extension.mjs
+++ b/plugins/the-workshop/extensions/signals-dashboard/extension.mjs
@@ -6,13 +6,26 @@
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 { join, delimiter, isAbsolute, sep } from "node:path";
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+import {
+ buildDeskAgentArgv,
+ isDeskProfile,
+ isSafeWindowsCmdShim,
+ isWindowsAppExecutionAlias,
+ normalizeDeskProfile,
+ parsePluginMcpNames,
+ quoteWindowsCmdArgument,
+} from "./launch-profile.mjs";
const servers = new Map();
const STASH_TTL_MS = 48 * 60 * 60 * 1000;
+const MCP_DISCOVERY_TTL_MS = 5 * 60 * 1000;
+const MCP_DISCOVERY_MAX_BYTES = 1024 * 1024;
+const DEFAULT_DESK_PROFILE = normalizeDeskProfile(process.env.WORKSHOP_DESK_PROFILE);
+const mcpDiscoveryCache = new Map();
// Serialize stash read-modify-write per workshop. The UI fires stash/restore
// POSTs without awaiting each other, so two overlapping mutations could both
@@ -83,48 +96,217 @@ function trySpawn(cmd, args, opts = {}) {
// 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; }
+ return probeExecutableFile(p).ok;
}
-function isOnPath(command) {
+function probeExecutableFile(p) {
+ try {
+ if (!statSync(p).isFile()) return { ok: false, errorCode: null };
+ if (process.platform !== "win32") accessSync(p, fsConstants.X_OK);
+ return { ok: true, errorCode: null };
+ } catch (error) {
+ return { ok: false, errorCode: error?.code || null };
+ }
+}
+
+function resolveOnPath(command, { directOnly = false, excludedRoot = null } = {}) {
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;
+ for (const rawDir of dirs) {
+ const dir = rawDir.replace(/^"(.*)"$/, "$1");
+ if (!dir || !isAbsolute(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;
+ for (const ext of exts) {
+ if (directOnly && ![".EXE", ".COM"].includes(ext.toUpperCase())) continue;
+ const candidate = join(dir, command + ext);
+ const probe = probeExecutableFile(candidate);
+ const appAlias = directOnly && probe.errorCode === "EACCES" &&
+ isWindowsAppExecutionAlias(candidate, process.env.LOCALAPPDATA);
+ if (!appAlias && !probe.ok) continue;
+ const resolved = appAlias ? candidate : realpathSync(candidate);
+ if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue;
+ return resolved;
+ }
+ } else {
+ const candidate = join(dir, command);
+ if (!isExecutableFile(candidate)) continue;
+ const resolved = realpathSync(candidate);
+ if (excludedRoot && isInsideRoot(excludedRoot, resolved)) continue;
+ return resolved;
}
}
} catch {}
- return false;
+ return null;
}
-// 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) {
+function resolveDeskAgent(workshopDir) {
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];
+ const agencyCommand = resolveOnPath("agency", { excludedRoot: workshopDir });
+ const copilotCommand = resolveOnPath("copilot", { excludedRoot: workshopDir });
+ // Explicit overrides are authoritative and fail closed when unavailable.
+ if (pref === "agency") {
+ return agencyCommand
+ ? { useAgency: true, agencyCommand, copilotCommand }
+ : null;
+ }
+ if (pref === "copilot") {
+ return copilotCommand
+ ? { useAgency: false, agencyCommand, copilotCommand }
+ : null;
+ }
+ if (agencyCommand) return { useAgency: true, agencyCommand, copilotCommand };
+ if (copilotCommand) return { useAgency: false, agencyCommand, copilotCommand };
+ return null;
+}
+
+function resolveSystem32Executable(name) {
+ if (process.platform !== "win32") return null;
+ const root = process.env.SystemRoot || process.env.WINDIR;
+ if (!root || !isAbsolute(root)) return null;
+ try {
+ const candidate = join(root, "System32", name);
+ return isExecutableFile(candidate) ? realpathSync(candidate) : null;
+ } catch {
+ return null;
+ }
+}
+
+function terminateProcessTree(child) {
+ if (!child || child.exitCode !== null) return;
+ if (process.platform === "win32" && child.pid) {
+ const taskkill = resolveSystem32Executable("taskkill.exe");
+ if (!taskkill) return;
+ try {
+ const killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
+ windowsHide: true,
+ stdio: "ignore",
+ });
+ killer.unref();
+ } catch {}
+ return;
+ }
+ try {
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
+ else child.kill("SIGKILL");
+ } catch {
+ try { child.kill("SIGKILL"); } catch {}
+ }
+}
+
+// Capture the underlying Copilot plugin inventory directly. Agency repo mode
+// separately suppresses its own default/config plugins, so discovery does not
+// need a wrapper process that can leave inherited pipes or descendants behind.
+function capturePluginMcpJson(workshopDir, agent) {
+ return new Promise((resolve) => {
+ const command = agent.copilotCommand;
+ if (!command) {
+ resolve(null);
+ return;
+ }
+ const args = ["plugins", "list", "--kind", "mcp", "--scope", "plugin", "--json"];
+ const shim = process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
+ if (shim && !isSafeWindowsCmdShim(command)) {
+ resolve(null);
+ return;
+ }
+ const spawnCommand = shim ? resolveSystem32Executable("cmd.exe") : command;
+ if (!spawnCommand) {
+ resolve(null);
+ return;
+ }
+ const spawnArgs = shim
+ ? ["/d", "/s", "/c",
+ `"${[command, ...args].map(quoteWindowsCmdArgument).join(" ")}"`]
+ : args;
+
+ let settled = false;
+ let stdout = "";
+ const done = (value, child, timer, terminate = false) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (terminate) {
+ try { child.stdout.destroy(); } catch {}
+ terminateProcessTree(child);
+ }
+ resolve(value);
+ };
+
+ let child;
+ try {
+ child = spawn(spawnCommand, spawnArgs, {
+ cwd: workshopDir,
+ detached: process.platform !== "win32",
+ windowsHide: true,
+ windowsVerbatimArguments: shim,
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ } catch {
+ resolve(null);
+ return;
+ }
+
+ const timer = setTimeout(() => done(null, child, timer, true), 30000);
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ if (settled) return;
+ stdout += chunk;
+ if (Buffer.byteLength(stdout, "utf8") > MCP_DISCOVERY_MAX_BYTES) {
+ done(null, child, timer, true);
+ return;
+ }
+ if (parsePluginMcpNames(stdout) !== null) done(stdout, child, timer, true);
+ });
+ child.on("error", () => done(null, child, timer));
+ child.on("close", (code) => done(code === 0 ? stdout : null, child, timer));
+ });
+}
+
+async function discoverPluginMcpNames(workshopDir, agent) {
+ const cacheKey = `${workshopDir}\0${agent.copilotCommand || "missing"}`;
+ const cached = mcpDiscoveryCache.get(cacheKey);
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
+
+ const output = await capturePluginMcpJson(workshopDir, agent);
+ const names = output === null ? null : parsePluginMcpNames(output);
+ const value = names === null
+ ? { ok: false, names: [] }
+ : { ok: true, names };
+ mcpDiscoveryCache.set(cacheKey, {
+ expiresAt: Date.now() + MCP_DISCOVERY_TTL_MS,
+ value,
+ });
+ return value;
+}
+
+// Preserve the existing Agency-aware launch and layer the repo profile on top.
+// Repo mode suppresses ambient plugin MCPs; connected mode keeps today's tool
+// surface. Plugin discovery fails open, while Agency repo mode still suppresses
+// Agency's own default MCPs.
+async function deskAgentArgv(deskName, workshopDir, profile) {
+ const resolved = resolveDeskAgent(workshopDir);
+ if (!resolved) return null;
+ const { useAgency, agencyCommand, copilotCommand } = resolved;
+ if (useAgency && !agencyCommand) return null;
+ if (!useAgency && !copilotCommand) return null;
+ const discovery = profile === "repo"
+ ? await discoverPluginMcpNames(workshopDir, resolved)
+ : { ok: true, names: [] };
+ return buildDeskAgentArgv({
+ deskName,
+ workshopDir,
+ useAgency,
+ agencyCommand,
+ copilotCommand,
+ profile,
+ pluginMcpNames: discovery.names,
+ discoverySucceeded: discovery.ok,
+ });
}
// A desk name flows onto a command line, and on the no-wt Windows fallback
@@ -172,7 +354,7 @@ function isInsideRoot(root, target) {
} catch { return false; }
}
-async function launchDeskConsole(deskPath, deskName, workshopDir) {
+async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFAULT_DESK_PROFILE) {
// 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
@@ -183,22 +365,30 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
if (!deskPath) return false;
if (!isSafeDeskNameForLaunch(deskName)) return false;
if (!isInsideRoot(workshopDir, deskPath)) return false;
- const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)];
+ const agent = await deskAgentArgv(deskName, workshopDir, profile);
+ if (!agent) return false;
+ const run = [...agent, "-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;
+ const wt = resolveOnPath("wt", { directOnly: true, excludedRoot: workshopDir });
+ const cmd = resolveSystem32Executable("cmd.exe");
+ const direct = /\.(exe|com)$/i.test(run[0]);
+ if (direct && wt && await trySpawn(wt, ["-d", deskPath, ...run])) return true;
+
+ // Older installs can expose .cmd/.bat shims. Only use cmd.exe when every
+ // argument is free of cmd metacharacters; otherwise fail closed and let
+ // the UI copy the desk path rather than reparse an unsafe workshop path.
+ const cmdSafe = run.every((arg) => !/[&|<>^%!()\r\n]/.test(arg));
+ if (cmdSafe && wt && cmd &&
+ await trySpawn(wt, ["-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 });
+ // still through cmd /k only when the arguments are safe for reparsing.
+ return cmdSafe && cmd
+ ? await trySpawn(cmd, ["/c", "start", "", cmd, "/k", ...run], { cwd: deskPath })
+ : false;
}
if (process.platform === "darwin") {
+ const osascript = "/usr/bin/osascript";
+ if (!isExecutableFile(osascript)) return false;
// 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
@@ -209,7 +399,7 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
" activate\n" +
" do script " + osaStringLiteral(line) + "\n" +
"end tell";
- return await trySpawn("osascript", ["-e", script]);
+ 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,
@@ -221,7 +411,8 @@ async function launchDeskConsole(deskPath, deskName, workshopDir) {
["xterm", ["-e", ...run]],
];
for (const [term, args] of linuxTerms) {
- if (await trySpawn(term, args, { cwd: deskPath })) return true;
+ const executable = resolveOnPath(term, { excludedRoot: workshopDir });
+ if (executable && await trySpawn(executable, args, { cwd: deskPath })) return true;
}
return false;
}
@@ -612,11 +803,20 @@ function renderSignalCard(sig) {
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 = ``;
+ title="Open this desk with the ${esc(DEFAULT_DESK_PROFILE)} tool profile">open`;
+ const connectedBtn = DEFAULT_DESK_PROFILE === "connected" ? "" : `
+ `;
let escalationBlock = "";
if (isEscalation && sig.escalationReason) {
@@ -719,6 +919,7 @@ function renderSignalCard(sig) {
${(sig.tokensIn || sig.tokensOut) ? `🪙 ${formatTokens(sig.tokensIn + sig.tokensOut)}` : ""}
${timeSince(sig.emittedAt)}${sig.signalCount ? ` · ${sig.signalCount}` : ""}
${openBtn}
+ ${connectedBtn}
${stashBtn}
@@ -850,14 +1051,16 @@ function renderDashboard(signals, stashed, capabilityToken) {
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
- async function openDesk(name) {
- const res = await fetch('/api/open/' + encodeURIComponent(name), POST_OPTS);
+ async function openDesk(name, profile) {
+ const selectedProfile = profile || ${JSON.stringify(DEFAULT_DESK_PROFILE)};
+ const res = await fetch('/api/open/' + encodeURIComponent(name) +
+ '?profile=' + encodeURIComponent(selectedProfile), POST_OPTS);
const data = await res.json();
if (data.ok) {
const path = data.deskPath || name;
if (data.launched) {
// A successful open shouldn't hijack the user's clipboard.
- showToast('opening ' + name + ' desk…', path);
+ showToast('opening ' + name + ' desk (' + selectedProfile + ')…', path);
} else {
// No terminal launched from here, so copy the path as the
// fallback handle, but only claim the copy when it actually
@@ -876,9 +1079,10 @@ function renderDashboard(signals, stashed, capabilityToken) {
const name = btn.getAttribute('data-desk');
if (!name) return;
const act = btn.getAttribute('data-act');
+ const profile = btn.getAttribute('data-profile');
if (act === 'stash') stashDesk(name);
else if (act === 'restore') restoreDesk(name);
- else if (act === 'open') openDesk(name);
+ else if (act === 'open') openDesk(name, profile);
});
async function refresh() {
try {
@@ -894,15 +1098,22 @@ function renderDashboard(signals, stashed, capabilityToken) {
const active = document.activeElement;
let focusKey = null;
if (active && active.matches && active.matches('button[data-act]')) {
- focusKey = active.getAttribute('data-act') + '|' + active.getAttribute('data-desk');
+ focusKey = JSON.stringify([
+ active.getAttribute('data-act'),
+ active.getAttribute('data-desk'),
+ active.getAttribute('data-profile') || '',
+ ]);
}
content.innerHTML = newContent.innerHTML;
if (focusKey) {
- const bar = focusKey.indexOf('|');
- const act = focusKey.slice(0, bar);
- const desk = focusKey.slice(bar + 1);
+ const [act, desk, profile] = JSON.parse(focusKey);
const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk;
- const target = content.querySelector('button[data-act="' + act + '"][data-desk="' + escDesk + '"]');
+ const profileSelector = profile
+ ? '[data-profile="' + profile + '"]'
+ : ':not([data-profile])';
+ const target = content.querySelector(
+ 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' +
+ profileSelector);
if (target) target.focus();
}
}
@@ -971,19 +1182,26 @@ async function startServer(instanceId, workshopDir) {
}
if (req.method === "POST" && url.pathname.startsWith("/api/open/")) {
const deskName = decodeURIComponent(url.pathname.split("/api/open/")[1]);
+ const profileInput = url.searchParams.get("profile") || DEFAULT_DESK_PROFILE;
if (!isValidDeskName(deskName)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid desk name" }));
return;
}
+ if (!isDeskProfile(profileInput)) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "Invalid desk profile" }));
+ return;
+ }
+ const profile = normalizeDeskProfile(profileInput);
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);
+ const launched = await launchDeskConsole(deskPath, deskName, workshopDir, profile);
res.writeHead(200, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ ok: true, deskName, deskPath, launched }));
+ res.end(JSON.stringify({ ok: true, deskName, deskPath, launched, profile }));
return;
}
} catch {}
@@ -1109,23 +1327,41 @@ const session = await joinSession({
},
{
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.",
+ description: "Open a desk as an in-place Copilot CLI session. Repo profile suppresses ambient plugin MCPs; connected keeps every configured tool. Returns the desk path, profile, and whether a terminal was launched.",
inputSchema: {
type: "object",
- properties: { deskName: { type: "string", description: "Name of the desk to open" } },
+ properties: {
+ deskName: { type: "string", description: "Name of the desk to open" },
+ profile: {
+ type: "string",
+ enum: ["repo", "connected"],
+ description: `Tool profile. Defaults to ${DEFAULT_DESK_PROFILE}.`,
+ },
+ },
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 profileInput = ctx.input.profile || DEFAULT_DESK_PROFILE;
+ if (!isDeskProfile(profileInput)) return { error: "Invalid desk profile" };
+ const profile = normalizeDeskProfile(profileInput);
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 };
+ const launched = await launchDeskConsole(
+ deskPath, ctx.input.deskName, entry.workshopDir, profile);
+ return {
+ ok: true,
+ deskName: ctx.input.deskName,
+ deskPath,
+ launched,
+ workshopDir: entry.workshopDir,
+ profile,
+ };
}
} catch {}
}
diff --git a/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs
new file mode 100644
index 00000000..4f06c6f2
--- /dev/null
+++ b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.mjs
@@ -0,0 +1,88 @@
+const PROFILES = new Set(["repo", "connected"]);
+const SAFE_MCP_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
+
+export function isDeskProfile(value) {
+ return typeof value === "string" && PROFILES.has(value.toLowerCase());
+}
+
+export function normalizeDeskProfile(value, fallback = "repo") {
+ return isDeskProfile(value) ? value.toLowerCase() : fallback;
+}
+
+export function isWindowsAppExecutionAlias(candidate, localAppData) {
+ if (typeof candidate !== "string" || typeof localAppData !== "string") return false;
+ const normalized = candidate.replaceAll("/", "\\").toLowerCase();
+ const root = `${localAppData.replaceAll("/", "\\").replace(/\\+$/, "")}` +
+ "\\microsoft\\windowsapps\\";
+ return normalized.startsWith(root.toLowerCase()) && normalized.endsWith(".exe");
+}
+
+export function quoteWindowsCmdArgument(value) {
+ return `"${String(value).replaceAll('"', '""')}"`;
+}
+
+export function isSafeWindowsCmdShim(value) {
+ return typeof value === "string" && !/[%\r\n]/.test(value);
+}
+
+export function parsePluginMcpNames(text) {
+ let parsed;
+ try { parsed = JSON.parse(text); }
+ catch {
+ // Agency may prefix its pass-through command with human-readable startup
+ // lines. Its underlying Copilot JSON is the final object in stdout.
+ let start = text.lastIndexOf("{");
+ while (start >= 0) {
+ try {
+ parsed = JSON.parse(text.slice(start));
+ break;
+ } catch {
+ start = text.lastIndexOf("{", start - 1);
+ }
+ }
+ if (start < 0) return null;
+ }
+
+ if (!Array.isArray(parsed?.plugins)) return null;
+
+ const names = [];
+ const seen = new Set();
+ for (const entry of parsed.plugins) {
+ if (entry?.kind !== "mcp" || entry.enabled === false) continue;
+ if (entry.scope !== "plugin" && entry.source !== "plugin") continue;
+ if (typeof entry.name !== "string" || !SAFE_MCP_NAME.test(entry.name)) continue;
+ if (!seen.has(entry.name)) {
+ seen.add(entry.name);
+ names.push(entry.name);
+ }
+ }
+ return names;
+}
+
+export function buildDeskAgentArgv({
+ deskName,
+ workshopDir,
+ useAgency,
+ agencyCommand = "agency",
+ copilotCommand = "copilot",
+ profile = "repo",
+ pluginMcpNames = [],
+ discoverySucceeded = true,
+}) {
+ const argv = useAgency
+ ? [agencyCommand, "copilot"]
+ : [copilotCommand, "--name", deskName];
+
+ if (profile === "repo") {
+ if (useAgency) argv.push("--no-default-mcps");
+ }
+
+ if (profile === "repo" && discoverySucceeded) {
+ for (const name of pluginMcpNames) {
+ if (SAFE_MCP_NAME.test(name)) argv.push("--disable-mcp-server", name);
+ }
+ }
+
+ argv.push("--add-dir", workshopDir);
+ return argv;
+}
diff --git a/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs
new file mode 100644
index 00000000..bde1f730
--- /dev/null
+++ b/plugins/the-workshop/extensions/signals-dashboard/launch-profile.test.mjs
@@ -0,0 +1,142 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ buildDeskAgentArgv,
+ isDeskProfile,
+ isSafeWindowsCmdShim,
+ isWindowsAppExecutionAlias,
+ normalizeDeskProfile,
+ parsePluginMcpNames,
+ quoteWindowsCmdArgument,
+} from "./launch-profile.mjs";
+
+test("normalizes supported profiles and defaults unknown values to repo", () => {
+ assert.equal(isDeskProfile("repo"), true);
+ assert.equal(isDeskProfile("CONNECTED"), true);
+ assert.equal(isDeskProfile("other"), false);
+ assert.equal(normalizeDeskProfile("CONNECTED"), "connected");
+ assert.equal(normalizeDeskProfile("other"), "repo");
+});
+
+test("recognizes Windows App Execution Alias paths without trusting repository executables", () => {
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe",
+ "C:\\Users\\person\\AppData\\Local"), true);
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\repo\\wt.exe",
+ "C:\\Users\\person\\AppData\\Local"), false);
+ assert.equal(isWindowsAppExecutionAlias(
+ "C:\\Users\\person\\AppData\\Local\\Microsoft\\WindowsApps\\wt.cmd",
+ "C:\\Users\\person\\AppData\\Local"), false);
+});
+
+test("quotes trusted cmd shim arguments and rejects percent-bearing paths", () => {
+ assert.equal(
+ quoteWindowsCmdArgument("C:\\Program Files\\Agency\\agency.cmd"),
+ "\"C:\\Program Files\\Agency\\agency.cmd\"");
+ assert.equal(quoteWindowsCmdArgument("--scope"), "\"--scope\"");
+ assert.equal(isSafeWindowsCmdShim("C:\\Program Files\\Agency\\agency.cmd"), true);
+ assert.equal(isSafeWindowsCmdShim("C:\\Users\\%USERNAME%\\agency.cmd"), false);
+});
+
+test("executes a Windows cmd shim with safe quoting", {
+ skip: process.platform !== "win32",
+}, () => {
+ const root = mkdtempSync(join(tmpdir(), "workshop-profile-"));
+ const shimDir = join(root, "Shim Name");
+ mkdirSync(shimDir);
+ const shim = join(shimDir, "copilot.cmd");
+ writeFileSync(shim, "@echo off\r\necho {\"plugins\":[]}\r\n");
+
+ const cmd = join(process.env.SystemRoot, "System32", "cmd.exe");
+ const commandLine = `"${[shim, "plugins", "list"]
+ .map(quoteWindowsCmdArgument)
+ .join(" ")}"`;
+ const result = spawnSync(cmd, ["/d", "/s", "/c", commandLine], {
+ encoding: "utf8",
+ windowsHide: true,
+ windowsVerbatimArguments: true,
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /\{"plugins":\[\]\}/);
+});
+
+test("extracts enabled plugin-scoped MCP names and rejects unsafe names", () => {
+ const names = parsePluginMcpNames(JSON.stringify({
+ plugins: [
+ { kind: "mcp", name: "teams", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "repo-mcp", source: "plugin", enabled: true },
+ { kind: "mcp", name: "teams", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "disabled", scope: "plugin", enabled: false },
+ { kind: "mcp", name: "workspace", scope: "repository", enabled: true },
+ { kind: "skill", name: "not-an-mcp", scope: "plugin", enabled: true },
+ { kind: "mcp", name: "bad;name", scope: "plugin", enabled: true },
+ ],
+ }));
+
+ assert.deepEqual(names, ["teams", "repo-mcp"]);
+ assert.deepEqual(parsePluginMcpNames(`Agency startup\n${JSON.stringify({
+ plugins: [{ kind: "mcp", name: "ado", scope: "plugin", enabled: true }],
+ })}`), ["ado"]);
+ assert.equal(parsePluginMcpNames("not json"), null);
+ assert.equal(parsePluginMcpNames("{}"), null);
+});
+
+test("builds an Agency repo profile on top of the existing wrapper", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "C:\\workshop",
+ useAgency: true,
+ agencyCommand: "C:\\tools\\agency.exe",
+ profile: "repo",
+ pluginMcpNames: ["teams", "ado"],
+ }), [
+ "C:\\tools\\agency.exe", "copilot", "--no-default-mcps",
+ "--disable-mcp-server", "teams",
+ "--disable-mcp-server", "ado",
+ "--add-dir", "C:\\workshop",
+ ]);
+});
+
+test("builds a plain Copilot repo profile without Agency-only flags", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: false,
+ copilotCommand: "/usr/local/bin/copilot",
+ profile: "repo",
+ pluginMcpNames: ["calendar"],
+ }), [
+ "/usr/local/bin/copilot", "--name", "cost-desk",
+ "--disable-mcp-server", "calendar",
+ "--add-dir", "/workshop",
+ ]);
+});
+
+test("connected preserves tools while Agency discovery failure still removes defaults", () => {
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: true,
+ profile: "connected",
+ pluginMcpNames: ["teams"],
+ }), [
+ "agency", "copilot", "--add-dir", "/workshop",
+ ]);
+
+ assert.deepEqual(buildDeskAgentArgv({
+ deskName: "cost-desk",
+ workshopDir: "/workshop",
+ useAgency: true,
+ profile: "repo",
+ pluginMcpNames: [],
+ discoverySucceeded: false,
+ }), [
+ "agency", "copilot", "--no-default-mcps", "--add-dir", "/workshop",
+ ]);
+});
diff --git a/plugins/the-workshop/extensions/signals-dashboard/package.json b/plugins/the-workshop/extensions/signals-dashboard/package.json
index b1625c1a..6165e97c 100644
--- a/plugins/the-workshop/extensions/signals-dashboard/package.json
+++ b/plugins/the-workshop/extensions/signals-dashboard/package.json
@@ -1,12 +1,15 @@
{
"name": "signals-dashboard",
- "version": "0.1.0",
+ "version": "0.2.0",
"type": "module",
"main": "extension.mjs",
+ "scripts": {
+ "test": "node --test launch-profile.test.mjs"
+ },
"dependencies": {
"@github/copilot-sdk": "latest"
},
- "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.",
+ "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.",
"keywords": [
"agent-signals",
"dashboard",