diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b5458c93e..fae81b25c 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1282,8 +1282,8 @@ { "name": "signals-dashboard", "source": "plugins/signals-dashboard", - "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", - "version": "0.2.0" + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation.", + "version": "0.3.0" }, { "name": "site-studio", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 3e9089d74..35569da3b 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -102,7 +102,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [rust-mcp-development](../plugins/rust-mcp-development/README.md) | Build high-performance Model Context Protocol servers in Rust using the official rmcp SDK with async/await, procedural macros, and type-safe implementations. | 2 items | rust, mcp, model-context-protocol, server-development, sdk, tokio, async, macros, rmcp | | [salesforce-development](../plugins/salesforce-development/README.md) | Complete Salesforce agentic development environment covering Apex & Triggers, Flow automation, Lightning Web Components, Aura components, and Visualforce pages. | 7 items | salesforce, apex, triggers, lwc, aura, flow, visualforce, crm, salesforce-dx | | [security-best-practices](../plugins/security-best-practices/README.md) | Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications. | 1 items | security, accessibility, performance, code-quality, owasp, a11y, optimization, best-practices | -| [signals-dashboard](../plugins/signals-dashboard/README.md) | Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles. | 1 items | agent-signals, dashboard, multi-agent, coordination, canvas | +| [signals-dashboard](../plugins/signals-dashboard/README.md) | Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation. | 1 items | agent-signals, dashboard, multi-agent, coordination, canvas, local-delegation | | [site-studio](../plugins/site-studio/README.md) | Plan, draft, and track a personal website section by section โ€” a shared canvas where you and your agent author content, watch progress, and review every change. | 1 items | agent-collaboration, content-authoring, personal-website, progress-tracking, section-planning, site-builder | | [skill-image-gen](../plugins/skill-image-gen/README.md) | Generate images using AI directly from your coding workflow. Supports OpenAI (gpt-image-2) and Google Gemini. BYO API key โ€” the skill guides you through setup on first use. | 1 items | image-generation, openai, gemini, ai, art, sprites, textures, icons | | [software-engineering-team](../plugins/software-engineering-team/README.md) | 7 specialized agents covering the full software development lifecycle from UX design and architecture to security and DevOps. | 7 items | team, enterprise, security, devops, ux, architecture, product, ai-ethics | diff --git a/extensions/signals-dashboard/README.md b/extensions/signals-dashboard/README.md new file mode 100644 index 000000000..c90b4ab8c --- /dev/null +++ b/extensions/signals-dashboard/README.md @@ -0,0 +1,145 @@ +# ๐Ÿชจ Cairn โ€” Signals Dashboard + +A live GitHub Copilot CLI **canvas extension** that shows the pulse of every +desk in your Workshop. It reads the agent signals your desks emit and renders +them as a compact, always-current dashboard in a side panel โ€” so you can direct +the work instead of polling each desk by hand. + +It replaces the old Blazor **WorkshopRoom** dashboard (`src/WorkshopRoom/`) with +a native canvas that runs inside GHCP, with no separate web app to launch. + +## What it is + +Each desk in the Workshop leaves signals behind โ€” small stones on the trail โ€” +by writing JSON files into its `.signals/` folder. Cairn scans those folders, +picks the most recent signal per desk, and renders: + +- **Score bars** for the desk's self-assessment (intent, confidence, accuracy, + completeness). +- **Patterns** the desk reported: what worked โœ“, what was hard โ–ณ, and skill + gaps โœ—. +- **Escalations** โ€” desks that raised their hand, with what they're blocked on + and their recommendation, pinned to the top and pulsing red. + +## How to open it + +The dashboard is registered as the **๐Ÿชจ Cairn** canvas (`signals-dashboard`). +Ask Copilot to open it and pass your workshop root as `workshopDir`: + +> Open the ๐Ÿชจ Cairn canvas with `workshopDir` set to the folder that contains +> my `desks/` directory. + +`workshopDir` must be the **absolute path to the workshop root** โ€” the folder +that holds `desks/` (and optionally `classroom/`). If omitted, it falls back to +the current working directory. + +## Features + +- **Signal scanning** โ€” walks `desks/*/.signals/` and `classroom/*/.signals/`, + reading the newest `*.json` per desk (mirrors `SignalReader.cs`). +- **Score bars** โ€” color-coded intent / confidence / accuracy / completeness, + scored out of 5. +- **Escalation alerts** โ€” escalation signals sort to the very top, render with a + pulsing red border, and surface the blocker + recommendation. +- **Active desks first** โ€” sorted escalations โ†’ recent signals โ†’ desks with no + signal yet, then by recency. +- **Stash / restore** โ€” pause a workstream by stashing its desk. Stashed desks + drop off the active view and auto-expire after a **48-hour TTL**; restore any + time before then. Stash state lives in `.desk-stash.json` at the workshop + root. +- **Auto-refresh** โ€” the panel refreshes every 5 seconds using a background + fetch (no full page reload), so scores and escalations stay current smoothly. +- **Summary bar** โ€” desk count, how many are reporting vs. awaiting, an + escalation badge, and average scores across the room. +- **Cost-aware desk launch** โ€” **open** starts a repo-profile desk that keeps the + verified Workshop root available while suppressing ambient plugin MCPs. + **connected** preserves every configured MCP for work that needs external + systems. Agency remains the preferred wrapper when installed. +- **Local Delegation** โ€” orthogonal off/on control. When available and on, the + frontier desk may use the installed [`local-agent-delegation`](https://github.com/jennyf19/sealed-delegation) + skill for bounded, independently gated read/evidence work. Fail-closed: no + skill or no qualified route receipt means the toggle cannot take effect, and + no local-savings credit is awarded. + +## Agent actions + +The canvas also exposes actions Copilot can invoke directly: + +- `refresh` โ€” force a rescan and return current signal data as JSON. +- `stash` โ€” stash a desk by `deskName`. +- `restore` โ€” restore a stashed desk by `deskName`. +- `open_desk` โ€” open a desk with optional `profile: "repo" | "connected"` and + optional `localDelegation: "off" | "on"`. + +## Desk launch profiles + +`repo` is the default. At launch, Cairn asks Copilot for the enabled +plugin-scoped MCP inventory and disables those ambient servers for the topic +desk. User-, workspace-, organization-, and built-in resources are left alone. +If discovery fails, Copilot plugin MCP suppression fails open; Agency repo mode +still omits Agency's own default MCPs. + +When Agency is installed, Cairn keeps the existing `agency copilot` launch and +adds Agency's `--no-default-mcps` in repo mode. Outside Agency, the same profile +is applied directly to Copilot CLI. + +Both profiles pass `--add-dir ` so a desk can intentionally read +another desk's journal or artifact without receiving access outside the room. + +Set `WORKSHOP_DESK_PROFILE=connected` to retain the historical default for the +main **open** button. The separate **connected** button is always available when +repo mode is the default. + +## Local Delegation + +Local Delegation is **not** a third desk profile and does not replace the frontier +model. It is a separate permission bit: + +```text +repo / connected = which MCPs and tools the frontier desk can see +Local Delegation off/on = whether the frontier desk may invoke a bounded local worker +``` + +Availability is fail-closed. Cairn enables the lane only when: + +1. the `local-agent-delegation` skill is installed, and +2. a qualified route is declared via `WORKSHOP_LOCAL_DELEGATION_ROUTE_ID` or a + receipt at `~/.copilot/local-agent-runs/qualified-route.json` + (`status: "qualified"`, safe `route_id`). + +Operator preference is stored **user-locally** under +`~/.copilot/workshop-local-delegation/` (keyed by the canonical workshop path), +never in the cloned workshop โ€” a repo cannot ship `preference: on`. +When the preference is on but availability fails, opens still launch as frontier +desks and surface the reason โ€” they never silently fall back with savings credit. + +When effective, Cairn sets `WORKSHOP_LOCAL_DELEGATION=enabled` on the launched +process, shows an open toast/badge (`Local Delegation effective ยท route โ€ฆ`), and +may append one short ASCII line to `-i` (`Local Delegation env is enabled.`) when +that combined prompt stays quote-free and under the length guard. Full policy +still lives in the env flag plus the installed `local-agent-delegation` skill โ€” +never a long multi-sentence `-i` appendix (Windows Terminal reparse). +The runtime, launcher, and gates remain owned by +[Sealed Delegation](https://github.com/jennyf19/sealed-delegation). + +## Signal shape + +Cairn reads the agent-signals protocol used across the Workshop: + +```json +{ + "signal_type": "execution", + "agent_name": "desk-name", + "self_assessment": { "intent": 5, "confidence": 4, "accuracy": 4, "completeness": 3 }, + "patterns": { "what_worked": "...", "what_was_hard": "...", "skill_gap": "..." }, + "escalation": { "reason": "...", "blocked_on": "...", "recommendation": "..." } +} +``` + +`escalation` is only present on `signal_type: "escalation"` signals. + +## Replaces the Blazor WorkshopRoom + +This canvas supersedes the standalone Blazor dashboard in `src/WorkshopRoom/`. +The data is the truth and the UI is just a view โ€” Cairn renders the same signal +data natively inside GHCP, so there's no separate server to run. diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs index 3309f0f5f..5a1d597e7 100644 --- a/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extension.mjs @@ -5,20 +5,33 @@ 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, isAbsolute, sep } from "node:path"; +import { readdir, readFile, writeFile, stat, rename, unlink, mkdir } from "node:fs/promises"; +import { join, delimiter, isAbsolute, sep, dirname } 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, + isSafeQuotedWindowsCmdArg, isSafeWindowsCmdShim, isWindowsAppExecutionAlias, normalizeDeskProfile, parsePluginMcpNames, quoteWindowsCmdArgument, } from "./launch-profile.mjs"; +import { + buildLocalDelegationLaunchEnv, + deskOrientPrompt, + formatLocalDelegationOpenNotice, + localDelegationPreferencePath, + normalizeLocalDelegationPreference, + parseLocalDelegationState, + resolveLocalDelegationAvailability, + resolveLocalDelegationLaunch, + serializeLocalDelegationState, + windowsLocalDelegationCmdPrefix, +} from "./local-delegation.mjs"; const servers = new Map(); const STASH_TTL_MS = 48 * 60 * 60 * 1000; @@ -60,11 +73,10 @@ function isValidDeskName(name) { // 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.`; -} +// +// Local Delegation is orthogonal to repo/connected: it never changes the tool +// surface. When effective, only the orientation prompt and child env mark that +// the frontier desk may use the installed local-agent-delegation skill. // 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 @@ -354,7 +366,62 @@ function isInsideRoot(root, target) { } catch { return false; } } -async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFAULT_DESK_PROFILE) { +function preferenceStatePath(workshopDir) { + return localDelegationPreferencePath(workshopDir, { + resolvePath: (p) => { + try { return realpathSync(p); } catch { return p; } + }, + }); +} + +async function readLocalDelegationPreference(workshopDir) { + // Never read preference from the workshop repo โ€” a clone can ship + // preference:on. Only user-local state (or explicit env) counts. + try { + const raw = JSON.parse(await readFile(preferenceStatePath(workshopDir), "utf8")); + return parseLocalDelegationState(raw).preference; + } catch { + return normalizeLocalDelegationPreference( + process.env.WORKSHOP_LOCAL_DELEGATION_PREFERENCE, "off"); + } +} + +async function writeLocalDelegationPreference(workshopDir, preference) { + const state = serializeLocalDelegationState({ preference }); + const target = preferenceStatePath(workshopDir); + await mkdir(dirname(target), { recursive: true }); + // Atomic replace in the user-local dir (temp + rename). + const tmp = join( + dirname(target), + `.pref.${process.pid}.${randomBytes(4).toString("hex")}.tmp`); + const body = JSON.stringify(state, null, 2) + "\n"; + try { + await writeFile(tmp, body, { encoding: "utf8", flag: "wx" }); + try { + await rename(tmp, target); + } catch { + await unlink(target).catch(() => {}); + await rename(tmp, target); + } + } catch (err) { + await unlink(tmp).catch(() => {}); + throw err; + } + return state; +} + +function currentLocalDelegationLaunch(preference) { + const availability = resolveLocalDelegationAvailability(); + return resolveLocalDelegationLaunch({ preference, availability }); +} + +async function launchDeskConsole( + deskPath, + deskName, + workshopDir, + profile = DEFAULT_DESK_PROFILE, + localDelegation = { effective: false }, +) { // 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 @@ -367,24 +434,27 @@ async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFA if (!isInsideRoot(workshopDir, deskPath)) return false; const agent = await deskAgentArgv(deskName, workshopDir, profile); if (!agent) return false; - const run = [...agent, "-i", deskOrientPrompt(deskName)]; + const effective = Boolean(localDelegation?.effective); + const run = [...agent, "-i", deskOrientPrompt(deskName, { localDelegationEffective: effective })]; + const env = buildLocalDelegationLaunchEnv(process.env, { localDelegationEffective: effective }); if (process.platform === "win32") { 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 only when the arguments are safe for reparsing. - return cmdSafe && cmd - ? await trySpawn(cmd, ["/c", "start", "", cmd, "/k", ...run], { cwd: deskPath }) - : false; + // wt.exe does not reliably forward Node's spawn env into a new tab when + // Windows Terminal is already running. Always start through cmd.exe and + // set/clear WORKSHOP_LOCAL_DELEGATION in the command string itself. + // Args are quoteWindowsCmdArgument'd, so only block expanders that still + // fire inside quotes (% and !) โ€” allow parentheses in workshop paths. + const cmdSafe = run.every((arg) => isSafeQuotedWindowsCmdArg(arg)); + if (!cmdSafe || !cmd) return false; + const inner = windowsLocalDelegationCmdPrefix(effective) + + run.map(quoteWindowsCmdArgument).join(" "); + if (wt && await trySpawn(wt, ["-d", deskPath, cmd, "/d", "/s", "/k", inner], { env })) { + return true; + } + // Fallback when wt.exe is absent: a fresh console window via `start`. + return await trySpawn( + cmd, ["/c", "start", "", cmd, "/d", "/s", "/k", inner], { cwd: deskPath, env }); } if (process.platform === "darwin") { const osascript = "/usr/bin/osascript"; @@ -393,13 +463,18 @@ async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFA // 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 " + + // Local-delegation env is exported in-line so the Terminal session sees it + // without inheriting a polluted parent shell forever. + const envPrefix = effective + ? "export WORKSHOP_LOCAL_DELEGATION=enabled; " + : "unset WORKSHOP_LOCAL_DELEGATION; "; + const line = "cd " + shSingleQuote(deskPath) + " && " + envPrefix + "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]); + return await trySpawn(osascript, ["-e", script], { env }); } // 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, @@ -412,7 +487,7 @@ async function launchDeskConsole(deskPath, deskName, workshopDir, profile = DEFA ]; for (const [term, args] of linuxTerms) { const executable = resolveOnPath(term, { excludedRoot: workshopDir }); - if (executable && await trySpawn(executable, args, { cwd: deskPath })) return true; + if (executable && await trySpawn(executable, args, { cwd: deskPath, env })) return true; } return false; } @@ -728,7 +803,42 @@ function avgScore(signals) { return { confidence: avg("confidence"), accuracy: avg("accuracy"), completeness: avg("completeness"), intent: avg("intentScore") }; } -function renderSummaryBar(activeSignals) { +function renderLocalDelegationControl(localDelegation) { + const pref = localDelegation?.preference || "off"; + const available = Boolean(localDelegation?.availability?.available); + const effective = Boolean(localDelegation?.effective); + const reason = localDelegation?.availability?.reason || "Local Delegation unavailable"; + const routeId = localDelegation?.availability?.routeId || null; + const next = pref === "on" ? "off" : "on"; + const label = effective ? "On" : (pref === "on" ? "On*" : "Off"); + const color = effective ? "#86efac" : (pref === "on" ? "#fbbf24" : "#94a3b8"); + const border = effective ? "#166534" : (pref === "on" ? "#854d0e" : "#334155"); + const title = available + ? (effective + ? `Local Delegation effective${routeId ? ` ยท route ${routeId}` : ""}` + : "Local Delegation available but currently off") + : reason; + const note = effective && routeId + ? `effective ยท ${esc(truncate(routeId, 28))}` + : !available + ? `${esc(truncate(reason, 48))}` + : (pref === "on" && !effective + ? `requested, unavailable` + : ""); + return ` +
+ Local + + ${note} +
`; +} + +function renderSummaryBar(activeSignals, localDelegation) { 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; @@ -763,13 +873,14 @@ function renderSummaryBar(activeSignals) { return `
-
+ background:#0f172a;border:1px solid #1e293b;border-radius:8px;margin-bottom:14px;gap:12px;flex-wrap:wrap;"> +
${activeSignals.length} desk${activeSignals.length !== 1 ? "s" : ""} ${withSignals} reporting ยท ${awaiting} awaiting ${tokenBadge} ${calibrationBadge} ${escBadge} + ${renderLocalDelegationControl(localDelegation)}
${avgBlock}
`; @@ -945,8 +1056,9 @@ function renderStashedCard(entry) {
`; } -function renderDashboard(signals, stashed, capabilityToken) { +function renderDashboard(signals, stashed, capabilityToken, localDelegation) { const activeSignals = sortSignals(signals.filter(s => !stashed.some(e => e.name === s.deskName))); + const localDelegationState = localDelegation || currentLocalDelegationLaunch("off"); const cards = activeSignals.length > 0 ? activeSignals.map(renderSignalCard).join("") @@ -975,7 +1087,9 @@ function renderDashboard(signals, stashed, capabilityToken) { `; - const summaryBar = activeSignals.length > 0 ? renderSummaryBar(activeSignals) : ""; + // Always show the Local Delegation control so operators can see availability + // even before the first desk signal arrives. + const summaryBar = renderSummaryBar(activeSignals, localDelegationState); const stashedSection = stashed.length > 0 ? `
@@ -1058,27 +1172,55 @@ function renderDashboard(signals, stashed, capabilityToken) { const data = await res.json(); if (data.ok) { const path = data.deskPath || name; + const notice = data.localDelegationNotice || {}; + const localTitle = notice.titleSuffix || ''; + const localDetail = notice.detail || ''; if (data.launched) { // A successful open shouldn't hijack the user's clipboard. - showToast('opening ' + name + ' desk (' + selectedProfile + ')โ€ฆ', path); + // Surface LD state in the toast โ€” operators cannot rely on -i alone. + showToast('opening ' + name + ' desk (' + selectedProfile + localTitle + ')โ€ฆ', + localDetail || path); } else { // No terminal launched from here, so copy the path as the // fallback handle, but only claim the copy when it actually // succeeded. The path shows in the toast either way. let copied = false; try { await navigator.clipboard.writeText(path); copied = true; } catch {} - showToast(copied ? (name + ' ยท path copied') : (name + ' ยท copy this path'), path); + const copyTitle = copied ? (name + ' ยท path copied') : (name + ' ยท copy this path'); + showToast(copyTitle + localTitle, localDetail || path); } } else { showToast(name + ' ยท not found', ''); } } + async function setLocalDelegation(preference) { + const res = await fetch('/api/local-delegation?preference=' + + encodeURIComponent(preference || 'off'), POST_OPTS); + const data = await res.json(); + if (data.ok) { + const ld = data.localDelegation || {}; + const routeId = ld.availability && ld.availability.routeId; + const label = ld.effective + ? ('Local Delegation effective' + (routeId ? (' ยท route ' + routeId) : '')) + : (ld.preference === 'on' + ? 'Local Delegation requested (unavailable)' + : 'Local Delegation off'); + showToast(label, ld.availability?.reason || ''); + refresh(); + } else { + showToast('Local Delegation ยท not updated', data.error || ''); + } + } document.addEventListener('click', (e) => { const btn = e.target.closest('button[data-act]'); if (!btn) return; + const act = btn.getAttribute('data-act'); + if (act === 'local-delegation') { + setLocalDelegation(btn.getAttribute('data-preference') || 'off'); + return; + } 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); @@ -1102,19 +1244,26 @@ function renderDashboard(signals, stashed, capabilityToken) { active.getAttribute('data-act'), active.getAttribute('data-desk'), active.getAttribute('data-profile') || '', + active.getAttribute('data-preference') || '', ]); } content.innerHTML = newContent.innerHTML; if (focusKey) { - const [act, desk, profile] = JSON.parse(focusKey); - const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk; - const profileSelector = profile - ? '[data-profile="' + profile + '"]' - : ':not([data-profile])'; - const target = content.querySelector( - 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' + - profileSelector); + const [act, desk, profile, preference] = JSON.parse(focusKey); + let target = null; + if (act === 'local-delegation') { + target = content.querySelector('button[data-act="local-delegation"]'); + } else { + const escDesk = (window.CSS && CSS.escape) ? CSS.escape(desk) : desk; + const profileSelector = profile + ? '[data-profile="' + profile + '"]' + : ':not([data-profile])'; + target = content.querySelector( + 'button[data-act="' + act + '"][data-desk="' + escDesk + '"]' + + profileSelector); + } if (target) target.focus(); + void preference; } } } catch {} @@ -1180,6 +1329,20 @@ async function startServer(instanceId, workshopDir) { res.end(JSON.stringify({ ok: true })); return; } + if (req.method === "POST" && url.pathname === "/api/local-delegation") { + const preferenceInput = url.searchParams.get("preference") || "off"; + if (!["off", "on"].includes(String(preferenceInput).toLowerCase())) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "Invalid local delegation preference" })); + return; + } + const preference = normalizeLocalDelegationPreference(preferenceInput, "off"); + await writeLocalDelegationPreference(workshopDir, preference); + const localDelegation = currentLocalDelegationLaunch(preference); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, localDelegation })); + return; + } 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; @@ -1194,14 +1357,25 @@ async function startServer(instanceId, workshopDir) { return; } const profile = normalizeDeskProfile(profileInput); + const preference = await readLocalDelegationPreference(workshopDir); + const localDelegation = currentLocalDelegationLaunch(preference); 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, profile); + const launched = await launchDeskConsole( + deskPath, deskName, workshopDir, profile, localDelegation); res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, deskName, deskPath, launched, profile })); + res.end(JSON.stringify({ + ok: true, + deskName, + deskPath, + launched, + profile, + localDelegation, + localDelegationNotice: formatLocalDelegationOpenNotice(localDelegation), + })); return; } } catch {} @@ -1213,8 +1387,10 @@ async function startServer(instanceId, workshopDir) { const signals = await scanSignals(workshopDir); const stashed = await readStash(workshopDir); + const preference = await readLocalDelegationPreference(workshopDir); + const localDelegation = currentLocalDelegationLaunch(preference); res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(renderDashboard(signals, stashed, capabilityToken)); + res.end(renderDashboard(signals, stashed, capabilityToken, localDelegation)); } catch (err) { // Top-level boundary: never leave a request hanging or let a // rejection become an unhandled crash โ€” e.g. malformed %-encoding @@ -1327,7 +1503,7 @@ const session = await joinSession({ }, { name: "open_desk", - 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.", + description: "Open a desk as an in-place Copilot CLI session. Repo profile suppresses ambient plugin MCPs; connected keeps every configured tool. Local Delegation is orthogonal and fail-closed: when available and preferred on, the frontier desk may use sealed local-agent-delegation for bounded read/evidence work. Returns the desk path, profile, localDelegation state, and whether a terminal was launched.", inputSchema: { type: "object", properties: { @@ -1337,6 +1513,11 @@ const session = await joinSession({ enum: ["repo", "connected"], description: `Tool profile. Defaults to ${DEFAULT_DESK_PROFILE}.`, }, + localDelegation: { + type: "string", + enum: ["off", "on"], + description: "Optional Local Delegation preference for this launch. Defaults to the workshop Cairn toggle (.local-delegation.json).", + }, }, required: ["deskName"], }, @@ -1347,13 +1528,21 @@ const session = await joinSession({ const profileInput = ctx.input.profile || DEFAULT_DESK_PROFILE; if (!isDeskProfile(profileInput)) return { error: "Invalid desk profile" }; const profile = normalizeDeskProfile(profileInput); + const preferenceInput = ctx.input.localDelegation + ?? await readLocalDelegationPreference(entry.workshopDir); + const preference = normalizeLocalDelegationPreference(preferenceInput, "off"); + const localDelegation = currentLocalDelegationLaunch(preference); 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, profile); + deskPath, + ctx.input.deskName, + entry.workshopDir, + profile, + localDelegation); return { ok: true, deskName: ctx.input.deskName, @@ -1361,6 +1550,8 @@ const session = await joinSession({ launched, workshopDir: entry.workshopDir, profile, + localDelegation, + localDelegationNotice: formatLocalDelegationOpenNotice(localDelegation), }; } } catch {} diff --git a/extensions/signals-dashboard/launch-profile.mjs b/extensions/signals-dashboard/launch-profile.mjs index 4f06c6f28..eeb4084fb 100644 --- a/extensions/signals-dashboard/launch-profile.mjs +++ b/extensions/signals-dashboard/launch-profile.mjs @@ -25,6 +25,16 @@ export function isSafeWindowsCmdShim(value) { return typeof value === "string" && !/[%\r\n]/.test(value); } +/** + * Args that will be wrapped with quoteWindowsCmdArgument. Inside double quotes, + * cmd still expands %VAR% and !VAR! (delayed expansion). Other metacharacters + * like & | < > ^ ( ) are literal when quoted, so paths such as + * C:\Work\Project (1) must be allowed. + */ +export function isSafeQuotedWindowsCmdArg(value) { + return typeof value === "string" && !/[%!\r\n]/.test(value); +} + export function parsePluginMcpNames(text) { let parsed; try { parsed = JSON.parse(text); } diff --git a/extensions/signals-dashboard/launch-profile.test.mjs b/extensions/signals-dashboard/launch-profile.test.mjs index bde1f7303..c99df844d 100644 --- a/extensions/signals-dashboard/launch-profile.test.mjs +++ b/extensions/signals-dashboard/launch-profile.test.mjs @@ -7,6 +7,7 @@ import { join } from "node:path"; import { buildDeskAgentArgv, isDeskProfile, + isSafeQuotedWindowsCmdArg, isSafeWindowsCmdShim, isWindowsAppExecutionAlias, normalizeDeskProfile, @@ -41,6 +42,10 @@ test("quotes trusted cmd shim arguments and rejects percent-bearing paths", () = assert.equal(quoteWindowsCmdArgument("--scope"), "\"--scope\""); assert.equal(isSafeWindowsCmdShim("C:\\Program Files\\Agency\\agency.cmd"), true); assert.equal(isSafeWindowsCmdShim("C:\\Users\\%USERNAME%\\agency.cmd"), false); + // Quoted args may contain parentheses (common workshop folders). + assert.equal(isSafeQuotedWindowsCmdArg("C:\\Work\\Project (1)"), true); + assert.equal(isSafeQuotedWindowsCmdArg("C:\\Users\\%USERNAME%\\w"), false); + assert.equal(isSafeQuotedWindowsCmdArg("C:\\Users\\!DELAY!\\w"), false); }); test("executes a Windows cmd shim with safe quoting", { diff --git a/extensions/signals-dashboard/local-delegation.mjs b/extensions/signals-dashboard/local-delegation.mjs new file mode 100644 index 000000000..3be0bc30a --- /dev/null +++ b/extensions/signals-dashboard/local-delegation.mjs @@ -0,0 +1,401 @@ +import { createHash } from "node:crypto"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const PREFERENCES = new Set(["off", "on"]); +const SAFE_ROUTE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +/** @deprecated Repo-root state is rejected; kept only for docs/migration mentions. */ +export const LOCAL_DELEGATION_STATE_FILE = ".local-delegation.json"; +export const LOCAL_DELEGATION_ENV = "WORKSHOP_LOCAL_DELEGATION"; +export const LOCAL_DELEGATION_SKILL_NAME = "local-agent-delegation"; +export const LOCAL_DELEGATION_USER_STATE_DIR = join(".copilot", "workshop-local-delegation"); + +/** + * Stable user-local preference path for a workshop root. + * Permission state must NOT live in the cloned workshop (a repo can ship + * preference:on). Key by a hash of the canonical workshop path under ~/.copilot. + */ +export function localDelegationPreferencePath(workshopDir, { + home = homedir(), + resolvePath = (p) => p, +} = {}) { + if (typeof workshopDir !== "string" || !workshopDir) { + throw new Error("workshopDir is required"); + } + let canonical = workshopDir; + try { canonical = resolvePath(workshopDir); } catch { /* keep input */ } + // Normalize separators only. Do not lowercase: on case-sensitive filesystems + // /work/Foo and /work/foo are distinct workshops and must not share state. + const key = createHash("sha256") + .update(String(canonical).replaceAll("\\", "/")) + .digest("hex") + .slice(0, 32); + return join(home, LOCAL_DELEGATION_USER_STATE_DIR, `${key}.json`); +} + +export function isLocalDelegationPreference(value) { + return typeof value === "string" && PREFERENCES.has(value.toLowerCase()); +} + +export function normalizeLocalDelegationPreference(value, fallback = "off") { + return isLocalDelegationPreference(value) ? value.toLowerCase() : fallback; +} + +// Windows Terminal / cmd reparse cannot safely carry long multi-space -i strings. +// Keep the orientation prompt short, ASCII, and quote-free. Local Delegation +// policy lives in WORKSHOP_LOCAL_DELEGATION=enabled + the installed skill โ€” not +// on the CLI. +const MAX_ORIENT_PROMPT_CHARS = 280; +// Allow apostrophes (desk's). Ban double quotes, backticks, dashes that WT/cmd +// have split on, and classic cmd metacharacters. +const UNSAFE_ORIENT_CHARS = /["`โ€”โ€“|&<>^%!()\r\n]/; + +export function isSafeDeskOrientPrompt(prompt) { + return typeof prompt === "string" + && prompt.length > 0 + && prompt.length <= MAX_ORIENT_PROMPT_CHARS + && !UNSAFE_ORIENT_CHARS.test(prompt); +} + +/** One short ASCII notice operators can see in the session start prompt. */ +export const LOCAL_DELEGATION_ORIENT_LINE = " Local Delegation env is enabled."; + +export function deskOrientPrompt(deskName, { localDelegationEffective = false } = {}) { + // deskName is already constrained to a slug by the launcher; still keep the + // prompt free of punctuation that cmd/wt have historically mis-parsed. + let prompt = `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.`; + // Only a single short ASCII line may ride on -i. Full policy stays in env + skill. + if (localDelegationEffective) { + const withNotice = prompt + LOCAL_DELEGATION_ORIENT_LINE; + if (isSafeDeskOrientPrompt(withNotice)) prompt = withNotice; + } + return prompt; +} + +/** + * Operator-visible summary for open toasts and badges. + * Never claims savings; only reports effective state + route id when known. + */ +export function formatLocalDelegationOpenNotice(localDelegation) { + if (!localDelegation || typeof localDelegation !== "object") { + return { titleSuffix: "", detail: "" }; + } + const routeId = localDelegation.availability?.routeId || null; + if (localDelegation.effective) { + const routePart = routeId ? ` ยท route ${routeId}` : ""; + return { + titleSuffix: " ยท Local Delegation effective", + detail: `Local Delegation effective${routePart}`, + }; + } + if (localDelegation.requested) { + return { + titleSuffix: " ยท local unavailable", + detail: localDelegation.warning + || localDelegation.availability?.reason + || "Local Delegation requested but unavailable", + }; + } + return { titleSuffix: "", detail: "" }; +} + +export function buildLocalDelegationLaunchEnv(baseEnv = {}, { localDelegationEffective = false } = {}) { + const env = { ...baseEnv }; + // Windows env names are case-insensitive; spreading process.env yields a + // case-sensitive object, so clear every spelling before optionally setting. + const target = LOCAL_DELEGATION_ENV.toLowerCase(); + for (const key of Object.keys(env)) { + if (key.toLowerCase() === target) delete env[key]; + } + if (localDelegationEffective) { + env[LOCAL_DELEGATION_ENV] = "enabled"; + } + return env; +} + +/** + * cmd.exe prefix that forces WORKSHOP_LOCAL_DELEGATION on or off inside a new + * Windows Terminal / console session. wt.exe does not reliably forward the + * caller's process env into a new tab when Terminal is already running. + */ +export function windowsLocalDelegationCmdPrefix(localDelegationEffective = false) { + return localDelegationEffective + ? 'set "WORKSHOP_LOCAL_DELEGATION=enabled"&& ' + : 'set "WORKSHOP_LOCAL_DELEGATION="&& '; +} + +/** + * Savings credit is utilization accounting, not a price claim. + * Failed, unaccepted, redone, or escalated local work earns zero. + */ +export function localSavingsCredit({ + attempted = false, + gateAccepted = false, + redone = false, + escalated = false, +} = {}) { + if (!attempted || !gateAccepted || redone || escalated) { + return { + credit: 0, + utilization: attempted ? "handled_locally_unaccepted" : "not_attempted", + reason: !attempted + ? "not_attempted" + : escalated + ? "escalated" + : redone + ? "redone" + : "gate_not_accepted", + }; + } + return { + credit: 0, // dollar savings are never claimed by Cairn + utilization: "handled_locally_accepted", + reason: "accepted_utilization_only", + }; +} + +function isReadableFile(path) { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, fsConstants.R_OK); + return true; + } catch { + return false; + } +} + +function skillMarkerPath(dir) { + return join(dir, "SKILL.md"); +} + +function looksLikeSkillDir(dir) { + return isReadableFile(skillMarkerPath(dir)); +} + +/** + * Discover the installed local-agent-delegation skill. + * Injectable probes keep unit tests filesystem-free. + */ +export function findLocalDelegationSkillDir({ + env = process.env, + home = homedir(), + exists = existsSync, + isSkillDir = looksLikeSkillDir, +} = {}) { + const explicit = (env.WORKSHOP_LOCAL_DELEGATION_SKILL_DIR || "").trim(); + if (explicit) { + return isSkillDir(explicit) ? explicit : null; + } + + const candidates = [ + join(home, ".copilot", "skills", LOCAL_DELEGATION_SKILL_NAME), + join(home, ".agents", "skills", LOCAL_DELEGATION_SKILL_NAME), + ]; + + for (const candidate of candidates) { + if (isSkillDir(candidate)) return candidate; + } + + // Marketplace: ~/.copilot/installed-plugins/// + // Direct: ~/.copilot/installed-plugins/_direct// + // Skill dirs may live at skills/, .github/skills/, or com.github.copilot/skills/. + const pluginsRoot = join(home, ".copilot", "installed-plugins"); + if (exists(pluginsRoot)) { + try { + for (const market of readdirSync(pluginsRoot, { withFileTypes: true })) { + if (!market.isDirectory()) continue; + const marketRoot = join(pluginsRoot, market.name); + let pluginEntries; + try { + pluginEntries = readdirSync(marketRoot, { withFileTypes: true }); + } catch { + continue; + } + for (const plugin of pluginEntries) { + if (!plugin.isDirectory()) continue; + const pluginRoot = join(marketRoot, plugin.name); + const nestedCandidates = [ + join(pluginRoot, "skills", LOCAL_DELEGATION_SKILL_NAME), + join(pluginRoot, ".github", "skills", LOCAL_DELEGATION_SKILL_NAME), + join(pluginRoot, "com.github.copilot", "skills", LOCAL_DELEGATION_SKILL_NAME), + join(pluginRoot, "com.github.awesome-copilot", "skills", LOCAL_DELEGATION_SKILL_NAME), + ]; + for (const nested of nestedCandidates) { + if (isSkillDir(nested)) return nested; + } + } + } + } catch { + // fail closed on scan errors + } + } + + return null; +} + +function readJsonFile(path, readFile = readFileSync) { + try { + return JSON.parse(readFile(path, "utf8")); + } catch { + return null; + } +} + +function isRouteId(value) { + return typeof value === "string" && SAFE_ROUTE_ID.test(value); +} + +/** + * Fail-closed availability. Enable only when skill + qualified route receipt + * (or explicit env route id) are present. Never invent availability. + */ +export function resolveLocalDelegationAvailability({ + env = process.env, + home = homedir(), + now = Date.now(), + findSkill = findLocalDelegationSkillDir, + readFile = readFileSync, + exists = existsSync, +} = {}) { + const forced = (env.WORKSHOP_LOCAL_DELEGATION_AVAILABLE || "").trim().toLowerCase(); + if (forced === "0" || forced === "false" || forced === "unavailable") { + return { + available: false, + reason: "Forced unavailable by WORKSHOP_LOCAL_DELEGATION_AVAILABLE", + skillDir: null, + routeId: null, + }; + } + + const skillDir = findSkill({ env, home, exists }); + if (!skillDir) { + return { + available: false, + reason: "local-agent-delegation skill is not installed", + skillDir: null, + routeId: null, + }; + } + + const envRoute = (env.WORKSHOP_LOCAL_DELEGATION_ROUTE_ID || "").trim(); + if (envRoute) { + if (!isRouteId(envRoute)) { + return { + available: false, + reason: "WORKSHOP_LOCAL_DELEGATION_ROUTE_ID is not a safe route id", + skillDir, + routeId: null, + }; + } + return { + available: true, + reason: "Skill installed; route id provided by environment", + skillDir, + routeId: envRoute, + }; + } + + const receiptPath = (env.WORKSHOP_LOCAL_DELEGATION_RECEIPT || "").trim() + || join(home, ".copilot", "local-agent-runs", "qualified-route.json"); + if (!exists(receiptPath)) { + return { + available: false, + reason: "No qualified route receipt (set WORKSHOP_LOCAL_DELEGATION_ROUTE_ID or write ~/.copilot/local-agent-runs/qualified-route.json)", + skillDir, + routeId: null, + }; + } + + const receipt = readJsonFile(receiptPath, readFile); + if (!receipt || typeof receipt !== "object") { + return { + available: false, + reason: "Qualified route receipt is unreadable", + skillDir, + routeId: null, + }; + } + + const status = String(receipt.status || "").toLowerCase(); + if (status !== "qualified") { + return { + available: false, + reason: `Route receipt status is '${receipt.status || "missing"}', not qualified`, + skillDir, + routeId: isRouteId(receipt.route_id || receipt.routeId) ? (receipt.route_id || receipt.routeId) : null, + }; + } + + const routeId = receipt.route_id || receipt.routeId || null; + if (!isRouteId(routeId)) { + return { + available: false, + reason: "Route receipt is missing a safe route_id", + skillDir, + routeId: null, + }; + } + + if (receipt.expires_at || receipt.expiresAt) { + const expires = Date.parse(receipt.expires_at || receipt.expiresAt); + if (!Number.isFinite(expires) || expires <= now) { + return { + available: false, + reason: "Qualified route receipt has expired", + skillDir, + routeId, + }; + } + } + + return { + available: true, + reason: "Skill installed; qualified route receipt present", + skillDir, + routeId, + }; +} + +export function resolveLocalDelegationLaunch({ + preference = "off", + availability, +} = {}) { + const pref = normalizeLocalDelegationPreference(preference, "off"); + const available = Boolean(availability?.available); + if (pref === "on" && !available) { + return { + preference: pref, + requested: true, + effective: false, + availability, + warning: availability?.reason || "Local Delegation unavailable", + }; + } + return { + preference: pref, + requested: pref === "on", + effective: pref === "on" && available, + availability, + warning: null, + }; +} + +export function parseLocalDelegationState(raw) { + if (!raw || typeof raw !== "object") { + return { preference: "off" }; + } + return { + preference: normalizeLocalDelegationPreference(raw.preference, "off"), + }; +} + +export function serializeLocalDelegationState(state) { + return { + preference: normalizeLocalDelegationPreference(state?.preference, "off"), + updatedAt: new Date().toISOString(), + }; +} diff --git a/extensions/signals-dashboard/local-delegation.test.mjs b/extensions/signals-dashboard/local-delegation.test.mjs new file mode 100644 index 000000000..d0962ecff --- /dev/null +++ b/extensions/signals-dashboard/local-delegation.test.mjs @@ -0,0 +1,298 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join, sep } from "node:path"; +import { + LOCAL_DELEGATION_ORIENT_LINE, + buildLocalDelegationLaunchEnv, + deskOrientPrompt, + findLocalDelegationSkillDir, + formatLocalDelegationOpenNotice, + isLocalDelegationPreference, + isSafeDeskOrientPrompt, + localDelegationPreferencePath, + localSavingsCredit, + normalizeLocalDelegationPreference, + parseLocalDelegationState, + resolveLocalDelegationAvailability, + resolveLocalDelegationLaunch, + serializeLocalDelegationState, + windowsLocalDelegationCmdPrefix, +} from "./local-delegation.mjs"; +import { + buildDeskAgentArgv, + normalizeDeskProfile, + quoteWindowsCmdArgument, +} from "./launch-profile.mjs"; + +test("normalizes local-delegation preference independently of desk profile", () => { + assert.equal(isLocalDelegationPreference("on"), true); + assert.equal(isLocalDelegationPreference("OFF"), true); + assert.equal(isLocalDelegationPreference("maybe"), false); + assert.equal(normalizeLocalDelegationPreference("ON"), "on"); + assert.equal(normalizeLocalDelegationPreference("nope"), "off"); + // Profile axis remains orthogonal and untouched. + assert.equal(normalizeDeskProfile("connected"), "connected"); + assert.equal(normalizeDeskProfile("repo"), "repo"); +}); + +test("availability is fail-closed without skill or route receipt", () => { + const missingSkill = resolveLocalDelegationAvailability({ + env: {}, + home: "C:\\home", + findSkill: () => null, + exists: () => false, + }); + assert.equal(missingSkill.available, false); + assert.match(missingSkill.reason, /not installed/i); + + const skillOnly = resolveLocalDelegationAvailability({ + env: {}, + home: "C:\\home", + findSkill: () => "C:\\home\\.copilot\\skills\\local-agent-delegation", + exists: () => false, + }); + assert.equal(skillOnly.available, false); + assert.match(skillOnly.reason, /No qualified route receipt/i); +}); + +test("availability accepts env route id or a qualified receipt", () => { + const home = join("home"); + const skillDir = join("skills", "local-agent-delegation"); + const viaEnv = resolveLocalDelegationAvailability({ + env: { WORKSHOP_LOCAL_DELEGATION_ROUTE_ID: "foundry-qwen25-7b-qualified" }, + home, + findSkill: () => skillDir, + exists: () => false, + }); + assert.equal(viaEnv.available, true); + assert.equal(viaEnv.routeId, "foundry-qwen25-7b-qualified"); + + // Match host path.join separators so Linux CI exercises the receipt branch. + const receiptPath = join(home, ".copilot", "local-agent-runs", "qualified-route.json"); + const viaReceipt = resolveLocalDelegationAvailability({ + env: {}, + home, + now: Date.parse("2026-08-14T12:00:00Z"), + findSkill: () => skillDir, + exists: (p) => p === receiptPath, + readFile: () => JSON.stringify({ + status: "qualified", + route_id: "foundry-qwen25-7b-qualified", + expires_at: "2026-12-01T00:00:00Z", + }), + }); + assert.equal(viaReceipt.available, true); + assert.equal(viaReceipt.routeId, "foundry-qwen25-7b-qualified"); + + const expired = resolveLocalDelegationAvailability({ + env: {}, + home, + now: Date.parse("2027-01-01T00:00:00Z"), + findSkill: () => skillDir, + exists: (p) => p === receiptPath, + readFile: () => JSON.stringify({ + status: "qualified", + route_id: "foundry-qwen25-7b-qualified", + expires_at: "2026-12-01T00:00:00Z", + }), + }); + assert.equal(expired.available, false); + assert.match(expired.reason, /expired/i); +}); + +test("requested on + unavailable stays ineffective with a warning", () => { + const launch = resolveLocalDelegationLaunch({ + preference: "on", + availability: { + available: false, + reason: "local-agent-delegation skill is not installed", + }, + }); + assert.equal(launch.requested, true); + assert.equal(launch.effective, false); + assert.match(launch.warning, /not installed/i); +}); + +test("launch env enables when effective; -i prompt stays short and quote-free", () => { + const base = { + PATH: "/usr/bin", + WORKSHOP_LOCAL_DELEGATION: "enabled", + workshop_local_delegation: "enabled", + }; + const offEnv = buildLocalDelegationLaunchEnv(base, { localDelegationEffective: false }); + assert.equal(Object.hasOwn(offEnv, "WORKSHOP_LOCAL_DELEGATION"), false); + assert.equal(Object.hasOwn(offEnv, "workshop_local_delegation"), false); + assert.equal(offEnv.PATH, "/usr/bin"); + + const onEnv = buildLocalDelegationLaunchEnv(base, { localDelegationEffective: true }); + assert.equal(onEnv.WORKSHOP_LOCAL_DELEGATION, "enabled"); + assert.equal(Object.hasOwn(onEnv, "workshop_local_delegation"), false); + + const offPrompt = deskOrientPrompt("cost-desk", { localDelegationEffective: false }); + const onPrompt = deskOrientPrompt("cost-desk", { localDelegationEffective: true }); + assert.match(offPrompt, /cost-desk/); + assert.equal(offPrompt.includes("Local Delegation"), false); + // One short ASCII line only โ€” never the long policy appendix. + assert.equal(onPrompt, offPrompt + LOCAL_DELEGATION_ORIENT_LINE); + assert.match(onPrompt, /Local Delegation env is enabled\./); + assert.equal(onPrompt.includes("do not delegate"), false); + assert.equal(isSafeDeskOrientPrompt(onPrompt), true); + assert.equal(isSafeDeskOrientPrompt('bad "quote"'), false); + assert.equal(isSafeDeskOrientPrompt("em dash โ€” bad"), false); + + assert.match(windowsLocalDelegationCmdPrefix(true), /enabled/); + assert.match(windowsLocalDelegationCmdPrefix(false), /WORKSHOP_LOCAL_DELEGATION="&&/); + const run = ["C:\\tools\\copilot.exe", "-i", onPrompt]; + const cmdLine = windowsLocalDelegationCmdPrefix(true) + run.map(quoteWindowsCmdArgument).join(" "); + assert.match(cmdLine, /^set "WORKSHOP_LOCAL_DELEGATION=enabled"&& /); + assert.match(cmdLine, /copilot\.exe/); +}); + +test("open notice reports effective route without savings claims", () => { + assert.deepEqual(formatLocalDelegationOpenNotice({ + effective: true, + requested: true, + availability: { available: true, routeId: "foundry-qwen25-7b-qualified" }, + }), { + titleSuffix: " ยท Local Delegation effective", + detail: "Local Delegation effective ยท route foundry-qwen25-7b-qualified", + }); + assert.deepEqual(formatLocalDelegationOpenNotice({ + effective: false, + requested: true, + warning: "local-agent-delegation skill is not installed", + availability: { available: false, reason: "local-agent-delegation skill is not installed" }, + }).detail, "local-agent-delegation skill is not installed"); + assert.deepEqual(formatLocalDelegationOpenNotice({ + effective: false, + requested: false, + }), { titleSuffix: "", detail: "" }); +}); + +test("repo/connected argv stays orthogonal to local-delegation preference", () => { + const repo = buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: false, + copilotCommand: "copilot", + profile: "repo", + pluginMcpNames: ["teams"], + }); + const connected = buildDeskAgentArgv({ + deskName: "cost-desk", + workshopDir: "/workshop", + useAgency: false, + copilotCommand: "copilot", + profile: "connected", + pluginMcpNames: ["teams"], + }); + assert.deepEqual(repo, [ + "copilot", "--name", "cost-desk", + "--disable-mcp-server", "teams", + "--add-dir", "/workshop", + ]); + assert.deepEqual(connected, [ + "copilot", "--name", "cost-desk", + "--add-dir", "/workshop", + ]); + // Local delegation never injects into argv โ€” only env/prompt. + assert.equal(repo.includes("local"), false); + assert.equal(connected.includes("local"), false); +}); + +test("failed or unaccepted local work earns zero savings credit", () => { + assert.equal(localSavingsCredit({ attempted: false }).credit, 0); + assert.equal(localSavingsCredit({ attempted: true, gateAccepted: false }).credit, 0); + assert.equal(localSavingsCredit({ attempted: true, gateAccepted: true, redone: true }).credit, 0); + assert.equal(localSavingsCredit({ attempted: true, gateAccepted: true, escalated: true }).credit, 0); + const accepted = localSavingsCredit({ attempted: true, gateAccepted: true }); + assert.equal(accepted.credit, 0); + assert.equal(accepted.utilization, "handled_locally_accepted"); +}); + +test("state parse/serialize defaults to off", () => { + assert.deepEqual(parseLocalDelegationState(null), { preference: "off" }); + assert.equal(parseLocalDelegationState({ preference: "ON" }).preference, "on"); + const serialized = serializeLocalDelegationState({ preference: "on" }); + assert.equal(serialized.preference, "on"); + assert.equal(typeof serialized.updatedAt, "string"); +}); + +test("preference path is user-local and keyed by workshop path, not the repo root", () => { + const home = join("user-home"); + const a = localDelegationPreferencePath(join("repos", "workshop-a"), { + home, + resolvePath: (p) => p, + }); + const b = localDelegationPreferencePath(join("repos", "workshop-b"), { + home, + resolvePath: (p) => p, + }); + const again = localDelegationPreferencePath(join("repos", "workshop-a"), { + home, + resolvePath: (p) => p, + }); + assert.match(a, /workshop-local-delegation/); + assert.equal(a.startsWith(home), true); + assert.equal(a.includes(`${sep}repos${sep}`), false); + assert.notEqual(a, b); + assert.equal(a, again); + // Repo-shipped .local-delegation.json is never the preference path. + assert.equal(a.endsWith(".local-delegation.json"), false); + + // Case-sensitive filesystems: Foo and foo must not share permission state. + const upper = localDelegationPreferencePath("/work/Foo", { + home, + resolvePath: (p) => p, + }); + const lower = localDelegationPreferencePath("/work/foo", { + home, + resolvePath: (p) => p, + }); + assert.notEqual(upper, lower); +}); + +test("skill discovery respects explicit dir and common install roots", () => { + const found = findLocalDelegationSkillDir({ + env: { WORKSHOP_LOCAL_DELEGATION_SKILL_DIR: "D:\\sealed\\.github\\skills\\local-agent-delegation" }, + home: "C:\\home", + exists: () => false, + isSkillDir: (p) => p === "D:\\sealed\\.github\\skills\\local-agent-delegation", + }); + assert.equal(found, "D:\\sealed\\.github\\skills\\local-agent-delegation"); + + const missingExplicit = findLocalDelegationSkillDir({ + env: { WORKSHOP_LOCAL_DELEGATION_SKILL_DIR: "D:\\missing" }, + home: "C:\\home", + exists: () => false, + isSkillDir: () => false, + }); + assert.equal(missingExplicit, null); +}); + +test("skill discovery walks marketplace/plugin and _direct install layouts", async () => { + const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const home = mkdtempSync(join(tmpdir(), "ld-skill-")); + try { + const skillDir = join( + home, ".copilot", "installed-plugins", "awesome-copilot", "sealed-delegation", + ".github", "skills", "local-agent-delegation"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "# local-agent-delegation\n"); + const found = findLocalDelegationSkillDir({ home, env: {} }); + assert.equal(found, skillDir); + + rmSync(join(home, ".copilot", "installed-plugins", "awesome-copilot"), { recursive: true, force: true }); + const direct = join( + home, ".copilot", "installed-plugins", "_direct", "sealed-delegation", + "skills", "local-agent-delegation"); + mkdirSync(direct, { recursive: true }); + writeFileSync(join(direct, "SKILL.md"), "# local-agent-delegation\n"); + const foundDirect = findLocalDelegationSkillDir({ home, env: {} }); + assert.equal(foundDirect, direct); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/extensions/signals-dashboard/package.json b/extensions/signals-dashboard/package.json index 6165e97c6..5fa278592 100644 --- a/extensions/signals-dashboard/package.json +++ b/extensions/signals-dashboard/package.json @@ -1,15 +1,15 @@ { "name": "signals-dashboard", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "main": "extension.mjs", "scripts": { - "test": "node --test launch-profile.test.mjs" + "test": "node --test launch-profile.test.mjs local-delegation.test.mjs" }, "dependencies": { "@github/copilot-sdk": "latest" }, - "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation.", "keywords": [ "agent-signals", "dashboard", diff --git a/plugins/signals-dashboard/README.md b/plugins/signals-dashboard/README.md index 96aed60c4..a97e81bcd 100644 --- a/plugins/signals-dashboard/README.md +++ b/plugins/signals-dashboard/README.md @@ -1,15 +1,27 @@ # Signals Dashboard Plugin -Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles. +Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware +desk profiles (`repo` / `connected`), and fail-closed Local Delegation. ## Installation -``bash +```bash copilot plugin install signals-dashboard@awesome-copilot -`` +``` + +## Features + +- Live desk signals, score bars, patterns, and escalations +- Cost-aware **open** (repo) and **connected** desk launch profiles +- **Local Delegation** toggle: when available, the frontier desk may use the + installed `local-agent-delegation` skill for bounded read/evidence work +- Fail-closed availability (skill + qualified route receipt); no silent savings credit ## Source +Canonical implementation: [jennyf19/the-workshop](https://github.com/jennyf19/the-workshop). +Local worker runtime: [jennyf19/sealed-delegation](https://github.com/jennyf19/sealed-delegation). + This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). ## License diff --git a/plugins/signals-dashboard/plugin.json b/plugins/signals-dashboard/plugin.json index 65db37c08..00059d8fe 100644 --- a/plugins/signals-dashboard/plugin.json +++ b/plugins/signals-dashboard/plugin.json @@ -1,7 +1,7 @@ { "name": "signals-dashboard", - "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", - "version": "0.2.0", + "description": "Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation.", + "version": "0.3.0", "author": { "name": "jennyf19", "url": "https://github.com/jennyf19" @@ -11,7 +11,8 @@ "dashboard", "multi-agent", "coordination", - "canvas" + "canvas", + "local-delegation" ], "extensions": { "com.github.copilot": { diff --git a/plugins/the-workshop/README.md b/plugins/the-workshop/README.md index a0aecb3f8..fc0c7cca8 100644 --- a/plugins/the-workshop/README.md +++ b/plugins/the-workshop/README.md @@ -45,6 +45,13 @@ Each desk card has two launch choices: - **connected** โ€” the full configured tool surface for work that needs external systems such as issue trackers, mail, or service APIs. +The summary bar also has a **Local** toggle. It is orthogonal to repo/connected: +when available and on, the frontier desk may use +[Sealed Delegation](https://github.com/jennyf19/sealed-delegation)'s +`local-agent-delegation` skill for bounded read/evidence work. Availability is +fail-closed (installed skill + qualified route receipt). Local output is a +proposal and never earns savings credit on its own. + 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.