feat: add the-workshop plugin — multi-agent coordination with persistent desks#2343
Conversation
…ent desks The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it. Components: - Workshop TA agent (room coordinator) - Skills: desk-open, desk-journal, signal-write, bench-read - Marketplace entry for one-command install Install: copilot plugin install the-workshop@awesome-copilot Complements Ember (partnership for one agent) with coordination for many agents. Install both for the full stack. Source: https://github.com/jennyf19/the-workshop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
|
🟡 Contributor Reputation Check: MEDIUM risk
Maintainers: please review this contributor before merging. |
🔒 PR Risk Scan ResultsScanned 15 changed file(s).
Skipped non-text or missing files
|
🔍 Vally Lint Results
Summary
Full linter output |
There was a problem hiding this comment.
Pull request overview
Adds The Workshop plugin for coordinating persistent multi-agent desks through shared journals, artifacts, and signals.
Changes:
- Adds the Workshop TA coordinator agent.
- Adds four desk-management and signaling skills.
- Adds plugin metadata, documentation, and marketplace registration.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
agents/workshop-ta.agent.md |
Defines the coordinator agent. |
skills/desk-open/SKILL.md |
Creates persistent desks. |
skills/desk-journal/SKILL.md |
Manages desk journals. |
skills/signal-write/SKILL.md |
Defines structured signals. |
skills/bench-read/SKILL.md |
Reads shared artifacts. |
plugins/the-workshop/README.md |
Documents installation and concepts. |
plugins/the-workshop/.github/plugin/plugin.json |
Declares plugin components. |
.github/plugin/marketplace.json |
Registers the plugin. |
- Add YAML front matter to workshop-ta agent (name + description) - Fix agent path: use 'workshop-ta' not './agents/workshop-ta.agent.md' - Sort skills alphabetically in plugin.json - Make Cairn dashboard reference conditional (full plugin from source repo) - Update signal-write: write JSON to .signals/ AND note in journal - Add partnership signal type to signal-write skill - Inline CAIRN disposition in agent (treat external CAIRN.md as optional) - Run npm run build to regenerate marketplace.json and docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
- plugin.json: use ./agents/workshop-ta.md path format (matches all other plugins) - workshop-ta front matter: name 'Workshop TA' preserves acronym (was 'workshop-ta') - signal-write: add subtype field (hands-up/blocked/done/checkpoint/partnership) so dashboard consumers can distinguish specific signal states - npm run build: regenerated docs/README.agents.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
… contract - desk-open: standard desk structure now creates .signals/ directory (prevents first signal-write from failing on missing parent dir) - signal-write: note that dashboard reads subtype field, falls back to signal_type for backward compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
- desk-open: add 'Session orientation' section explaining the session→journal→signals lifecycle. Desks are long-running in state (journal), not runtime (each session is independent). - workshop-ta: partnership signals write to desks/_ta/.signals/ so they appear on the dashboard without replacing any desk's latest signal. TA uses the _ta prefix to indicate coordinator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
Never initialize over existing journal.md — if the desk directory already exists, resume it instead. Operator must explicitly rename or archive before reusing a desk name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
…n non-terminal Two follow-up review findings: - signals-dashboard: the async createServer callback had no top-level error boundary, so a malformed %-encoded path, a stash write on a read-only workshop, or a scan failure rejected a promise the server never awaits — hanging the request and risking an unhandled-rejection crash. Wrap the handler and return a controlled 500. - workshop-create: Path A treated finding a marker (desks/, CAIRN.md, etc.) as 'just use it', an early stop that left partially initialized workshops incomplete. Make it detection-only and continue scaffolding whatever is missing, per the 'only add what's missing' principle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
extensions/signals-dashboard/extension.mjs:45
- The same-origin check trusts the request's
Hostheader. A DNS-rebinding request can sendHost: attacker.example:<port>andOrigin: http://attacker.example:<port>, pass this equality check, and invoke the state-changing stash/restore APIs. Compare against the canonical127.0.0.1:<bound-port>origin and reject noncanonical Host headers;extensions/connector-namespaces/server.mjs:126-155demonstrates both checks.
function isCrossSiteRequest(req) {
const origin = req.headers.origin;
if (origin) {
if (origin === `http://${req.headers.host}`) return false;
if (origin === "null") return true;
extensions/signals-dashboard/extension.mjs:652
- An
asyncHTTP listener's rejected promise is not handled by Node. Here malformed percent encoding in anydecodeURIComponentcall, or a failed stash write, rejects the listener and can terminate the extension instead of returning an HTTP error. Invoke an async request handler from a non-async listener and attach a catch that sends a 400/500 response, as done inextensions/connector-namespaces/server.mjs:576-584.
async function startServer(instanceId, workshopDir) {
const server = createServer(async (req, res) => {
try {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
extensions/signals-dashboard/extension.mjs:44
- The same-origin exception trusts the request's
Hostheader, so a DNS-rebinding request withHost: attacker.example:<port>and the matchingOriginis accepted as local and can invoke the stash/restore endpoints. The repository's canonical implementation explicitly compares against the captured loopback origin and rejects noncanonical hosts (extensions/connector-namespaces/server.mjs:126-155, tested atserver.test.mjs:76-80). Capture this server's127.0.0.1:<port>origin after binding and validate against that value instead ofreq.headers.host.
function isCrossSiteRequest(req) {
const origin = req.headers.origin;
if (origin) {
if (origin === `http://${req.headers.host}`) return false;
extensions/signals-dashboard/extension.mjs:76
- These stash updates are unprotected read-modify-write operations. Two quick stash/restore requests can both read the same array and then overwrite each other, losing one user's state change; the delegated click handler can issue exactly such concurrent requests. Serialize mutations per workshop (or use an atomic locked update) so each operation reads the result of the previous one.
async function stashDesk(workshopDir, deskName) {
const stash = await readStash(workshopDir);
if (stash.some(e => e.name === deskName)) return stash;
stash.push({ name: deskName, stashedAt: new Date().toISOString() });
await writeStash(workshopDir, stash);
signals-dashboard imports '@github/copilot-sdk/extension' but shipped without a package.json — the only SDK-importing extension in the repo missing one (every peer that imports the SDK declares it). Without the manifest the host has no dependency to resolve at load time. Add a package.json matching the peer shape (type: module, main: extension.mjs, '@github/copilot-sdk': latest — the modal peer convention). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (6)
extensions/signals-dashboard/extension.mjs:46
- The same-origin check derives trust from the attacker-controlled
Hostheader. A DNS-rebinding request can send matchingHost/Originvalues, pass this gate, fetch dashboard data, and invoke/api/opento expose local paths. Capture the canonical127.0.0.1:<bound-port>origin after listening, reject noncanonical hosts, and compareOriginonly to that value (as inextensions/connector-namespaces/server.mjs:38-39,141-154).
if (origin) {
if (origin === `http://${req.headers.host}`) return false;
if (origin === "null") return true;
if (/^https?:\/\//i.test(origin)) return true;
extensions/signals-dashboard/extension.mjs:134
- Filesystem mtime is not a persistent event timestamp: cloning or checking out a Git-backed workshop resets mtimes. The dashboard can then select an old signal as latest and report it as newly emitted. Add a validated
emitted_atfield to the signal schema (or a standardized filename timestamp fallback) and use that for ordering/display.
const s = await stat(fp);
const raw = await readFile(fp, "utf-8");
const parsed = JSON.parse(raw);
allSignals.push({ parsed, mtimeMs: s.mtimeMs, path: fp });
extensions/signals-dashboard/extension.mjs:160
- Time proximity does not establish correlation. If execution B is latest and an outcome for execution A arrives afterward within an hour, this pairs A's rating with B and computes a false honesty gap; it can also attach an outcome to an escalation. Require a matching
run_idor another explicit correlation ID, and leave uncorrelated outcomes unpaired.
if (!outcome) {
const recentOutcomes = allSignals
.filter(s => s.parsed.signal_type === "outcome" && s.mtimeMs >= latestTime && (s.mtimeMs - latestTime) < 3600000)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
if (recentOutcomes.length > 0) outcome = recentOutcomes[0].parsed;
extensions/signals-dashboard/extension.mjs:132
- Every refresh stats, reads, and parses every historical signal for every desk. Since
refresh()runs every five seconds and signals accumulate for long-running desks, I/O grows without bound. Maintain a latest/outcome index or cache parsed files and bound retained history so refresh cost does not scale with the full archive.
for (const f of jsonFiles) {
const fp = join(sigDir, f);
try {
const s = await stat(fp);
const raw = await readFile(fp, "utf-8");
extensions/signals-dashboard/extension.mjs:141
- If every JSON file is malformed or is an outcome without a primary signal,
latestremains null and this removes the desk entirely. Empty or missing signal directories correctly render the desk as awaiting, so one bad agent-written file should not make it disappear. Emit the same no-signal record here, optionally with an invalid-signal status.
if (!latest) continue;
extensions/signals-dashboard/extension.mjs:76
- This read-modify-write is not serialized. Two quick stash requests can both read the same array and the last write drops the other desk; the click handlers launch these requests concurrently. Serialize stash mutations per workshop and use an atomic temp-file rename for persistence.
async function stashDesk(workshopDir, deskName) {
const stash = await readStash(workshopDir);
if (stash.some(e => e.name === deskName)) return stash;
stash.push({ name: deskName, stashedAt: new Date().toISOString() });
await writeStash(workshopDir, stash);
The dashboard is a canvas extension that ships standalone, not bundled in the plugin. Installing the-workshop delivers skills/agent/desks only; the signals-dashboard canvas installs separately and renders in the GitHub Copilot app. Replaces the false 'bundled... nothing else to install' claim with the real two-install path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
The signals-dashboard canvas is a standalone extension, not bundled in the-workshop plugin. Removes the same inaccurate 'bundled' phrasing the README fix corrected, keeping the subtype guidance intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
extensions/signals-dashboard/extension.mjs:653
- The loopback server trusts the request's
Hostheader as its canonical origin. A DNS-rebound request can therefore send matching attacker-controlledHost/Originvalues, read the default dashboard response (including local signal contents), and pass the POST CSRF check. Reject any host other than the actual127.0.0.1:<listening-port>before routing, asextensions/connector-namespaces/server.mjs:138-155does.
const url = new URL(req.url, `http://${req.headers.host}`);
extensions/signals-dashboard/extension.mjs:134
- A valid JSON file can still parse to
nullor an array.nullis added toallSignalsbeforeparsed.signal_typethrows; if another valid latest signal exists, the later outcome filters dereference that retainednull, the outer catch drops the entire desk, and its card disappears. Skip non-object signal documents before retaining them.
const parsed = JSON.parse(raw);
allSignals.push({ parsed, mtimeMs: s.mtimeMs, path: fp });
extensions/signals-dashboard/extension.mjs:296
withOutcomescan be positive whilegapsis empty—for example, an outcome paired with a signal that has no confidence score. In that case the summary renders a greengap nullbadge becausenull <= 1. Base the badge count and average on outcomes that actually produced an honesty gap.
const withOutcomes = activeSignals.filter(s => s.outcomeRating !== null).length;
const avgGap = (() => {
const gaps = activeSignals.filter(s => s.honestyGap !== null).map(s => s.honestyGap);
return gaps.length ? (gaps.reduce((a, b) => a + b, 0) / gaps.length).toFixed(1) : null;
})();
plugins/the-workshop/README.md:44
- This install guidance contradicts the manifest and the TA documentation:
x-awesome-copilot.extensionscauses materialization to copysignals-dashboardintothe-workshop, so installing the plugin already includes it (eng/materialize-plugins.mjs:214-233). Requiring a second install misleads users; present the standalone command as optional instead.
It ships as a separate extension. Install it alongside the plugin to get the live canvas:
copilot plugin install signals-dashboard@awesome-copilot
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (6)
extensions/signals-dashboard/extension.mjs:136
- Filesystem mtime is used as the emission time for latest-signal selection, outcome matching, and displayed age. Git does not preserve mtimes, so cloning or checking out a repository-backed workshop can reorder historical signals and pair an outcome with the wrong execution. Persist a stable emission timestamp in the signal schema (or define a sortable filename format) and use that, with mtime only as a legacy fallback.
if ((parsed.signal_type || "execution") !== "outcome" && s.mtimeMs > latestTime) {
extensions/signals-dashboard/extension.mjs:134
- A valid JSON primitive such as
nullis pushed intoallSignalsbefore property access fails. Later outcome filters dereference everyparsedvalue, so that one malformed file triggers the outer catch and removes an otherwise valid desk from the dashboard. Reject non-object records before storing them.
const parsed = JSON.parse(raw);
allSignals.push({ parsed, mtimeMs: s.mtimeMs, path: fp });
extensions/signals-dashboard/extension.mjs:308
- When outcomes exist but none has a computable honesty gap,
avgGapisnull; JavaScript then treats it as zero in the color comparison and the summary displays a greengap null. Render this badge only when a gap was actually calculated.
const calibrationBadge = withOutcomes > 0
? `<span style="font-size:11px;color:${avgGap <= 1 ? '#22c55e' : avgGap <= 2 ? '#eab308' : '#ef4444'};" title="${withOutcomes} outcome signal${withOutcomes > 1 ? 's' : ''}, avg gap: ${avgGap}">🔍 gap ${avgGap}</span>`
: "";
extensions/signals-dashboard/extension.mjs:570
- This transient toast is the only confirmation that copying the desk path succeeded or failed, but dynamically inserted content without a live-region role is not announced by screen readers. Mark it as a polite status message.
function showToast(title, detail) {
const toast = document.createElement('div');
extensions/signals-dashboard/extension.mjs:792
- This action claims to open a new session, but its handler only validates the directory and returns a path; it never creates or navigates to a session. That can make callers assume the desk is active when no session was opened. Either perform the supported session action or describe this as path resolution.
description: "Open a desk as a new session in the GHCP app. Returns the desk path so the agent can create_session or navigate to it.",
plugins/the-workshop/README.md:40
- The plugin manifest bundles
signals-dashboardviax-awesome-copilot.extensions, so the main install already includes it. Telling users to install it alongside the plugin can register a duplicate copy; document the standalone command as an alternative instead.
It ships as a separate extension. Install it alongside the plugin to get the live canvas:
…a, error handling - extension.mjs: serialize stash read-modify-write behind a per-desk mutex; order signals by explicit ISO timestamp (clone-safe) not mtime; keep desks with malformed latest signal as 'awaiting'; gate calibration badge on a real gap value; add prefers-reduced-motion CSS; toast aria-live region; rename open_desk -> get_desk_path (returns path, no session); handle server.listen errors. - signal-write/SKILL.md: document timestamp + run_id, ordering guidance, and the outcome (calibration) signal schema. - workshop-ta.agent.md: a desk is a persistent workstream picked up by independent sessions, not one long-running process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (4)
extensions/signals-dashboard/extension.mjs:88
readStashis called by unlocked GET/refresh paths, but expiration cleanup writes the stash file here. That bypasseswithStashLock, so a cleanup racing withstashDesk/restoreDeskcan overwrite the newer mutation; a cleanup write failure also makes the catch return an empty stash. Keep reads side-effect free and let locked mutations persist their filtered result.
const now = Date.now();
const live = stash.filter(e => (now - new Date(e.stashedAt).getTime()) < STASH_TTL_MS);
if (live.length !== stash.length) await writeStash(workshopDir, live);
return live;
extensions/signals-dashboard/extension.mjs:164
- A valid JSON value such as
nullis appended toallSignalsbeforeparsed.signal_typethrows. If the directory also contains a valid latest signal, the later outcome filters dereference that retainednull, and the outer catch drops the desk from the dashboard entirely. Reject non-object JSON before adding it toallSignals.
const parsed = JSON.parse(raw);
const emittedMs = signalTime(parsed, s.mtimeMs);
allSignals.push({ parsed, mtimeMs: emittedMs, path: fp });
extensions/signals-dashboard/extension.mjs:203
- The recent-outcome fallback also runs when the latest signal has a
run_idbut no matching outcome, so it can attach another run's nearby outcome and report a false honesty gap. The skill contract says temporal fallback applies only whenrun_idis absent; gate this branch accordingly.
// Also check for any recent outcome (within 1hr of latest signal) if no run_id match
if (!outcome) {
const recentOutcomes = allSignals
.filter(s => s.parsed.signal_type === "outcome" && s.mtimeMs >= latestTime && (s.mtimeMs - latestTime) < 3600000)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
if (recentOutcomes.length > 0) outcome = recentOutcomes[0].parsed;
}
plugins/the-workshop/README.md:43
- This contradicts both the plugin manifest and the TA instructions:
x-awesome-copilot.extensionscauses materialization to bundlesignals-dashboardwiththe-workshop(eng/materialize-plugins.mjs:214-233). Requiring a second install unnecessarily installs the same extension again; describe the standalone command as optional instead.
It ships as a separate extension. Install it alongside the plugin to get the live canvas:
copilot plugin install signals-dashboard@awesome-copilot
</details>
| function toScore(v, max = 5) { | ||
| const n = Number(v); | ||
| if (!Number.isFinite(n)) return 0; | ||
| return Math.max(0, Math.min(max, n)); | ||
| } |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (8)
extensions/signals-dashboard/extension.mjs:700
- The loopback server trusts the request
Hostwhen constructing both the URL and the allowed Origin. A DNS-rebinding origin can therefore makeOriginequal the attacker-controlledHost, bypass the cross-site check, and read dashboard signal data or call the API. Validate every request against the actual127.0.0.1:<localPort>host (or require an unguessable per-instance token) before serving any route.
const url = new URL(req.url, `http://${req.headers.host}`);
extensions/signals-dashboard/extension.mjs:164
JSON.parsecan successfully returnnull, an array, or a primitive. Such a file is then added toallSignals; a laters.parsed.signal_typeaccess can throw and the outer empty catch drops the entire desk from the dashboard. Reject non-object signal documents before adding them.
const parsed = JSON.parse(raw);
const emittedMs = signalTime(parsed, s.mtimeMs);
allSignals.push({ parsed, mtimeMs: emittedMs, path: fp });
extensions/signals-dashboard/extension.mjs:210
- A score of
0is valid according tosignal-write, but the> 0condition treats either zero score as missing and suppresses the honesty gap. Check whether both raw values are present and numeric, then compute from their clamped scores.
if (outcome && sig.self_assessment) {
const selfConf = toScore(sig.self_assessment.confidence);
const outcomeRating = toScore(outcome.quality_rating);
if (selfConf > 0 && outcomeRating > 0) {
extensions/signals-dashboard/extension.mjs:237
- The
|| nullconversion turns the documented, validquality_rating: 0into “no outcome,” so the dashboard hides a zero-quality result entirely. Preserve zero while still rejecting missing or nonnumeric ratings.
outcomeRating: outcome ? (toScore(outcome.quality_rating) || null) : null,
extensions/signals-dashboard/extension.mjs:161
- Every dashboard refresh stats, reads, and parses every historical signal file sequentially. Because
signal-writecreates a new file per signal and the UI repeats this scan every five seconds, I/O and refresh latency grow without bound for the long-running desks this feature targets. Keep a per-desk cache/index (invalidated by directory changes), persist a latest-signal summary, or introduce retention so refresh work is proportional to desks/new signals rather than all history.
for (const f of jsonFiles) {
const fp = join(sigDir, f);
try {
const s = await stat(fp);
const raw = await readFile(fp, "utf-8");
plugins/the-workshop/README.md:40
- This says users must install the dashboard alongside the plugin, but this plugin's manifest includes
./extensions/signals-dashboard/inx-awesome-copilot.extensions, and the TA documentation says it ships bundled. Clarify that the standalone install is optional; otherwise users are instructed to install the same extension twice.
It ships as a separate extension. Install it alongside the plugin to get the live canvas:
extensions/signals-dashboard/extension.mjs:87
readStashis also called outsidewithStashLockfor every dashboard render and refresh action, yet it writes expired-entry cleanup here. That write can race a locked stash/restore and overwrite its newer array, silently losing a user's mutation. Keep reads side-effect free; the next locked stash/restore already writes the filtered array and can persist cleanup safely.
const now = Date.now();
const live = stash.filter(e => (now - new Date(e.stashedAt).getTime()) < STASH_TTL_MS);
if (live.length !== stash.length) await writeStash(workshopDir, live);
extensions/signals-dashboard/extension.mjs:409
- An escalation that supplies
blocked_on(or only a recommendation) but leavesreasonnull is valid under the documented signal shape, yet this guard hides the entire escalation detail block. Render the block when any escalation detail is present so blocked desks do not lose the actionable information.
if (isEscalation && sig.escalationReason) {
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Medium
| const stashBtn = `<button data-act="stash" data-desk="${esc(sig.deskName)}" | ||
| style="background:none;border:1px solid #1e293b;color:#475569;padding:2px 8px;border-radius:4px; | ||
| font-size:11px;cursor:pointer;transition:all .15s;" |
What
Adds the-workshop — a multi-agent coordination framework where long-running AI agents (desks) work in the same room, on the same work, each with its own memory and history.
Install
Why it belongs here
The Workshop complements Ember (already on awesome-copilot):
Install both for the full stack.
Components
Key concepts
Source
jennyf19/the-workshop — includes a canvas extension (signals dashboard) for the GHCP app.
Files
plugins/the-workshop/— plugin entry (plugin.json+ README)agents/workshop-ta.agent.md— TA coordinator agentskills/{desk-open,desk-journal,signal-write,bench-read}/— skills.github/plugin/marketplace.json— marketplace entry added