diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index f08731cc..4c3ab1da 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -613,6 +613,12 @@
"description": "Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai",
"version": "1.0.0"
},
+ {
+ "name": "edit-tutorial",
+ "source": "plugins/edit-tutorial",
+ "description": "Turn the code edits Copilot made in your session into an interactive lesson: a step-by-step walkthrough of each change with before/after views and quizzes, then a hands-on exercise that varies those edits so you finish the change yourself.",
+ "version": "1.0.0"
+ },
{
"name": "elasticsearch",
"description": "Official Elastic plugin for GitHub Copilot — translate natural language to ES|QL queries, ingest data, manage Elasticsearch security (authn, authz, audit), and troubleshoot clusters. Powered by the official Elastic agent skills repository.",
diff --git a/docs/README.plugins.md b/docs/README.plugins.md
index 792b0710..71c54c3b 100644
--- a/docs/README.plugins.md
+++ b/docs/README.plugins.md
@@ -57,6 +57,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t
| [diagram-viewer](../plugins/diagram-viewer/README.md) | Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas. | 1 items | architecture-mapping, canvas-navigation, exploratory-analysis, interactive-diagrams, node-drilldown, relationship-visualization |
| [doublecheck](../plugins/doublecheck/README.md) | Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting. | 0 items | verification, hallucination, fact-check, source-citation, trust, safety |
| [edge-ai-tasks](../plugins/edge-ai-tasks/README.md) | Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai | 0 items | architecture, planning, research, tasks, implementation |
+| [edit-tutorial](../plugins/edit-tutorial/README.md) | Turn the code edits Copilot made in your session into an interactive lesson: a step-by-step walkthrough of each change with before/after views and quizzes, then a hands-on exercise that varies those edits so you finish the change yourself. | 1 items | agent-edits, code-walkthrough, guided-exercise, interactive-tutorial, learning, onboarding |
| [ember](../plugins/ember/README.md) | An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find. | 0 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience |
| [eyeball](../plugins/eyeball/README.md) | Document analysis with inline source screenshots. When you ask Copilot to analyze a document, Eyeball generates a Word doc where every factual claim includes a highlighted screenshot from the source material so you can verify it with your own eyes. | 0 items | document-analysis, citation-verification, screenshot, contracts, legal, trust, visual-verification |
| [fastah-ip-geo-tools](../plugins/fastah-ip-geo-tools/README.md) | This plugin is for network operations engineers who wish to tune and publish IP geolocation feeds in RFC 8805 format. It consists of an AI Skill and an associated MCP server that geocodes geolocation place names to real cities for accuracy. | 0 items | geofeed, ip-geolocation, rfc-8805, rfc-9632, network-operations, isp, cloud, hosting, ixp |
diff --git a/extensions/edit-tutorial/README.md b/extensions/edit-tutorial/README.md
new file mode 100644
index 00000000..622f763e
--- /dev/null
+++ b/extensions/edit-tutorial/README.md
@@ -0,0 +1,197 @@
+# Edit Tutorial
+
+A canvas extension that turns a set of code changes into an interactive lesson: the
+edits the agent made in the current session, or the changes in a commit (the last
+commit by default) when the agent made none. The lesson is a step-by-step walkthrough
+of each change, comprehension quizzes, and a hands-on exercise the learner finishes in
+the canvas in order to understand the updates to the source code.
+
+
+
+## Prerequisites
+
+- **Node.js 20.19 or newer** because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
+- The GitHub Copilot app canvas / UI-extensions experiment enabled.
+
+## Install
+
+Drop this folder at `~/.copilot/extensions/edit-tutorial/` for user scope, or in a repository at
+`.github/extensions/edit-tutorial/` for project scope. Then install dependencies from inside the
+copied folder:
+
+```sh
+# User scope
+cd ~/.copilot/extensions/edit-tutorial
+
+# Or project scope, from the repository root
+cd .github/extensions/edit-tutorial
+
+npm install
+```
+
+Reload extensions in the GitHub Copilot app, then when updating a repository using the Copilot app,
+add a line like:
+
+```text
+Start an edit tutorial for the update.
+```
+
+at the end of the prompt to start the `edit-tutorial` canvas. To learn an existing
+change without the agent editing anything, ask on its own:
+
+```text
+Start an edit tutorial from the last commit.
+```
+
+## What It Does
+
+- **Two sources**: the code edits the agent made in the current session, or the
+ changes in a commit when there are no session edits. The most recent commit is the
+ default; name any commit to learn that one instead.
+- **Lesson history**: each newly titled lesson joins a history of up to 10. When
+ more than one exists, arrows under the progress counter let the learner flip
+ between lessons, each keeping its own progress. Republishing the active lesson with
+ the same title replaces it instead of adding a new one.
+- **Walkthrough**: one step per focused edit, each with the file, an explanation, a
+ before/after code view with change highlighting, and an optional multiple-choice quiz.
+- **Exercise**: finishing the walkthrough unlocks a hands-on task that applies the same
+ technique as the session's edits, but as a slight variation (a different function,
+ module, or parameter values), so the learner writes the change themselves instead of
+ rereading it.
+- **Completion**: local regex checks validate the attempt, hints reveal one at a time,
+ a reference solution unlocks after repeated failed attempts, and the learner can send
+ their code to the agent for a coaching review. Passing the checks, or an approving
+ review, completes the lesson.
+- **Persistence**: lesson content and learner progress are saved to the session
+ workspace, so reopening the canvas resumes where the learner left off. Each save
+ also refreshes a read-only HTML snapshot of the rendered lesson, so the tutorial
+ survives an app restart as a readable artifact instead of raw state data. All
+ Edit Tutorial canvases open in one session share this same lesson state, so a
+ second canvas is another view of the same lessons, not a separate copy.
+
+For example, if the agent added retry-with-backoff logic to `fetchUser`, the lesson
+walks through that change and then asks the learner to apply the same pattern to
+`fetchOrders` with a different attempt cap and starting delay.
+
+## Preserved Artifact
+
+The live canvas is served by the extension process on a loopback port, so it cannot
+outlive the app. Two files are written to the session workspace on every save:
+
+| File | Purpose |
+| --- | --- |
+| `files/edit-tutorial-state.json` | Machine state used to restore the live canvas on reopen |
+| `files/edit-tutorial-artifact.html` | Self-contained, read-only rendering of the lesson and progress |
+
+After the app is closed and reopened, the conversation can preserve the HTML artifact
+in place of the live canvas: it renders the full walkthrough, quiz results, revealed
+hints, and the learner's exercise attempt, with no scripts, no token, and no server
+behind it. When the history holds several lessons, the artifact shows the active one
+and labels it ("Lesson 2 of 3"). The artifact also embeds the state document, lesson
+history included, in a non-executing JSON block, so the lessons can be rebuilt from
+the artifact alone if the state file is ever lost.
+
+That rebuild is automatic. A chat resumes the same session, so the canvas restores
+from the state file directly. A project reopens into a fresh session whose workspace
+holds only what was preserved from the conversation; when the canvas opens and finds
+no state file, the extension scans the workspace for a preserved artifact (under its
+original name or a copied one) and restores the lessons and progress from its
+embedded state block, so the canvas survives wherever the artifact does.
+
+Reopening the canvas is automatic. In a session attached to a repository, the app
+brings the workspace files back after a restart but does not reopen the canvas on
+its own. Shortly after the session starts, the extension checks a few times whether
+a lesson is stored while no canvas has opened, waiting out the app still restoring
+files, and then sends the session a reopen request itself, the same message a
+stranded learner would type by hand, at most one message per session start. It also hands
+the agent the same instruction as context at session start and on each prompt. A
+canvas the learner closed on purpose stays closed, and if the canvas still does not
+come back, the manual paths below always work.
+
+### If the canvas does not come back after a restart
+
+Three manual paths bring the lesson back immediately in a repository session, best
+first:
+
+- **Reopen from the app menu**: click the "+" icon, choose "Extensions", then
+ "Edit Tutorial". The canvas opens and restores the stored lessons and progress
+ from disk on its own. This is the fastest path and needs no agent turn.
+- **Continue by asking**: ask Copilot to "reopen the edit-tutorial canvas". Opening
+ the canvas restores everything the same way; nothing is lost by the canvas having
+ been closed, and asking does not rebuild or reset the lesson.
+- **Read it now**: in the session's Files panel, open `edit-tutorial-artifact.html`
+ and choose "Open in browser". That page is the preserved lesson, progress
+ included, as a read-only snapshot, and it tells you how to resume.
+
+## Usage
+
+1. Let the agent make a change to your code, then open the Edit Tutorial canvas and
+ click "Build my tutorial" (or just ask: "teach me what you changed"). If the agent
+ made no edits in the session, the lesson is built from the last commit instead;
+ you can also ask for that directly: "teach me the last commit".
+2. The agent reviews the session's edits, or the commit's changes, and publishes the
+ lesson to the canvas with the `set_tutorial` action.
+3. Work through the steps, answer the quizzes, and finish the exercise in the canvas
+ editor.
+
+## Canvas Actions
+
+| Action | Purpose |
+| --- | --- |
+| `set_tutorial` | Publish a lesson (title, summary, source, steps, exercise); a new title adds to the lesson history, the active title replaces |
+| `get_progress` | Read the learner's step progress and current exercise attempt |
+| `approve_exercise` | Mark the exercise complete after a successful review |
+| `reset_progress` | Restart the current lesson without changing its content |
+
+### Example `set_tutorial` Payload
+
+Quizzes are optional per step. Each `solutionChecks` entry is a JavaScript regular
+expression the learner's attempt must match; its `hint` is shown when the check fails.
+
+
+
+Show Details
+
+```json
+{
+ "title": "Retry with exponential",
+ "summary": "The API client now retries transient failures with exponential.",
+ "source": "Commit a1b2c3d: retry transient API failures",
+ "steps": [
+ {
+ "file": "src/api/client.js",
+ "heading": "Wrap the request in a retry loop",
+ "explanation": "The single request call becomes a bounded loop.",
+ "before": "const res = await get(\"/users/\" + id);",
+ "after": "for (let attempt = 1; attempt <= maxAttempts; attempt++) { ... }",
+ "quiz": {
+ "question": "Why bound the loop?",
+ "options": ["To avoid retrying forever", "To speed up requests"],
+ "answerIndex": 0,
+ "why": "A bounded loop guarantees the call eventually settles."
+ }
+ }
+ ],
+ "exercise": {
+ "heading": "Your turn: retry the orders endpoint",
+ "brief": "Apply the same pattern to fetchOrders, capped at 5 attempts.",
+ "file": "src/api/orders.js",
+ "starterCode": "async function fetchOrders(customerId) { ... }",
+ "hints": ["Start from the loop shape used in fetchUser."],
+ "solutionChecks": [
+ { "pattern": "maxAttempts\\s*=\\s*5", "hint": "Cap the attempts at 5" }
+ ],
+ "solution": "async function fetchOrders(customerId) { ... }"
+ }
+}
+```
+
+
+
+## Distribution
+
+This extension is shipped through the `edit-tutorial` plugin:
+
+```bash
+copilot plugin install edit-tutorial@awesome-copilot
+```
diff --git a/extensions/edit-tutorial/assets/icon.png b/extensions/edit-tutorial/assets/icon.png
new file mode 100644
index 00000000..7802191e
Binary files /dev/null and b/extensions/edit-tutorial/assets/icon.png differ
diff --git a/extensions/edit-tutorial/assets/preview.png b/extensions/edit-tutorial/assets/preview.png
new file mode 100644
index 00000000..7008a46d
Binary files /dev/null and b/extensions/edit-tutorial/assets/preview.png differ
diff --git a/extensions/edit-tutorial/copilot-extension.json b/extensions/edit-tutorial/copilot-extension.json
new file mode 100644
index 00000000..7dbdb71c
--- /dev/null
+++ b/extensions/edit-tutorial/copilot-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "edit-tutorial",
+ "version": 1
+}
diff --git a/extensions/edit-tutorial/extension.mjs b/extensions/edit-tutorial/extension.mjs
new file mode 100644
index 00000000..3b92ff54
--- /dev/null
+++ b/extensions/edit-tutorial/extension.mjs
@@ -0,0 +1,3201 @@
+// Extension: edit-tutorial
+// Learn-by-doing canvas. The agent publishes a tutorial built from a set of
+// code changes: the edits it made in the current session, or the changes in a
+// commit (the repository's last commit by default) when it made none. The
+// lesson is a step-by-step walkthrough of each change (with optional
+// comprehension quizzes) followed by a hands-on exercise that applies the same
+// technique as a slight variation. The learner completes the exercise in the
+// canvas; local regex checks or an agent review mark it finished. Each newly
+// titled lesson joins a small history, and arrows in the canvas header let the
+// learner flip between lessons without losing progress in any of them.
+
+import { createServer } from "node:http";
+import { readFile, writeFile, rename, rm, mkdir, readdir, stat } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { randomUUID } from "node:crypto";
+import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
+
+const servers = new Map(); // instanceId -> { server, url }
+const sseClients = new Map(); // instanceId -> Set
+
+// One lesson state for the whole session, shared by every canvas instance.
+// Persistence writes to fixed per-session filenames (STATE_FILENAME and
+// ARTIFACT_FILENAME under the session workspace), so state scoped per instance
+// would let two open canvases hold different lessons while silently overwriting
+// each other's saves, and a reopen would restore whichever instance wrote last.
+// Memory is therefore scoped the way persistence is scoped: every canvas is a
+// view of the same lesson history. Servers and event streams stay per instance,
+// since they are per-window plumbing, and broadcasts reach the clients of all
+// of them.
+let sessionState = null;
+
+const MAX_STEPS = 12;
+const MAX_CODE_CHARS = 20000;
+const MAX_CHECKS = 10;
+const MAX_HINTS = 5;
+const MAX_LESSONS = 10;
+const STATE_FILENAME = "edit-tutorial-state.json";
+const ARTIFACT_FILENAME = "edit-tutorial-artifact.html";
+
+let sessionRef = null;
+
+// --- Input normalization ---
+
+function text(value, max) {
+ if (typeof value !== "string") return "";
+ return value.trim().slice(0, max);
+}
+
+function code(value) {
+ if (typeof value !== "string") return "";
+ return value.replace(/\s+$/, "").slice(0, MAX_CODE_CHARS);
+}
+
+// --- Solution check safety ---
+
+// solutionChecks patterns are authored by the agent and run against whatever the
+// learner typed, so a syntactically valid expression can still hang the canvas by
+// backtracking catastrophically. Two layers guard that: the canvas evaluates
+// checks in a worker with a hard time budget, and this screen refuses the known
+// explosive shapes at publish time so the agent gets an actionable error instead
+// of shipping a lesson that stalls.
+//
+// The explosive shape is a repeated group whose body can match the same input in
+// more than one way: (a+)+, (\s*\w+)*, (\w+,\s*)+. A fixed-width body such as
+// (\d{4})+ has only one possible split, so it stays allowed.
+
+// Reads the quantifier starting at index i, if there is one. `repeats` means the
+// atom can apply more than once; `ambiguous` means it can apply a variable number
+// of times; `unbounded` means it has no upper limit.
+function readQuantifier(src, i) {
+ const ch = src[i];
+ if (ch === "*" || ch === "+") return { length: 1, repeats: true, ambiguous: true, unbounded: true };
+ // "?" is variable width, so a body containing one is ambiguous: (a?){100} is
+ // a real blowup. A "?" on the group itself only makes it optional, which is
+ // why `repeats` stays false.
+ if (ch === "?") return { length: 1, repeats: false, ambiguous: true, unbounded: false };
+ if (ch !== "{") return null;
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(src.slice(i));
+ if (!m) return null; // a literal brace, not a quantifier
+ const min = Number(m[1]);
+ const openEnded = m[2] !== undefined && m[3] === "";
+ const max = m[2] === undefined ? min : openEnded ? Infinity : Number(m[3]);
+ return { length: m[0].length, repeats: max >= 2, ambiguous: openEnded || max > min, unbounded: openEnded };
+}
+
+// True when a group body can consume the same text in more than one way, which is
+// what turns an enclosing repetition into exponential backtracking.
+function bodyIsAmbiguous(body) {
+ let inClass = false;
+ for (let i = 0; i < body.length; i++) {
+ const ch = body[i];
+ if (ch === "\\") { i++; continue; }
+ if (inClass) { if (ch === "]") inClass = false; continue; }
+ if (ch === "[") { inClass = true; continue; }
+ const q = readQuantifier(body, i);
+ if (q) {
+ if (q.ambiguous) return true;
+ i += q.length - 1;
+ }
+ }
+ return false;
+}
+
+// Alternation at any depth counts: ((a|aa))+ is just as explosive as (a|aa)+.
+function bodyHasAlternation(body) {
+ let inClass = false;
+ for (let i = 0; i < body.length; i++) {
+ const ch = body[i];
+ if (ch === "\\") { i++; continue; }
+ if (inClass) { if (ch === "]") inClass = false; continue; }
+ if (ch === "[") { inClass = true; continue; }
+ if (ch === "|") return true;
+ }
+ return false;
+}
+
+// Returns null when the pattern is safe to run, or a short reason when it is not.
+// Deliberately conservative: it walks the source rather than parsing it fully, and
+// would rather refuse an exotic-but-safe pattern than let a stalling one through.
+function screenPattern(pattern) {
+ const stack = [];
+ let inClass = false;
+ for (let i = 0; i < pattern.length; i++) {
+ const ch = pattern[i];
+ if (ch === "\\") { i++; continue; }
+ if (inClass) { if (ch === "]") inClass = false; continue; }
+ if (ch === "[") { inClass = true; continue; }
+ if (ch === "(") { stack.push(i); continue; }
+ if (ch !== ")") continue;
+
+ const open = stack.pop();
+ if (open === undefined) continue; // unbalanced; the RegExp compile below reports it
+ const q = readQuantifier(pattern, i + 1);
+ if (!q || !q.repeats) continue;
+ const body = pattern.slice(open + 1, i);
+ if (bodyIsAmbiguous(body)) {
+ return "it repeats a group whose body also repeats, such as (a+)+ or (\\s*\\w+)*";
+ }
+ // A bound does not make overlapping alternatives safe: (a|aa){100} has as
+ // many ways to split its input as (a|aa)+ does, so any repeat counts.
+ if (bodyHasAlternation(body)) {
+ return "it repeats a group containing alternatives, such as (a|ab)+ or (a|aa){100}";
+ }
+ }
+ return null;
+}
+
+// Validates one solutionChecks entry. Returns { check } or { error }.
+function normalizeCheck(raw) {
+ const pattern = typeof raw?.pattern === "string" ? raw.pattern : "";
+ if (!pattern) return {};
+ if (pattern.length > 500) {
+ return { error: "solutionChecks pattern must be 500 characters or fewer." };
+ }
+ // Default only when flags are omitted. A hand-picked allowlist rejected valid
+ // flags such as u, y and d and quietly substituted "m", running the check with
+ // semantics the author did not ask for; RegExp is the authority on what is
+ // valid, and anything it refuses becomes an error the agent can act on.
+ const flags = typeof raw?.flags === "string" ? raw.flags : "m";
+ try {
+ new RegExp(pattern, flags);
+ } catch {
+ return {
+ error:
+ "solutionChecks entry is not a valid regular expression: /" + pattern + "/" + flags +
+ ". Check both the pattern and the flags.",
+ };
+ }
+ const unsafe = screenPattern(pattern);
+ if (unsafe) {
+ return {
+ error:
+ "solutionChecks pattern can hang on a near match because " + unsafe + ": " + pattern +
+ ". Match the repeated part once instead, or give it a fixed width like (\\d{4})+.",
+ };
+ }
+ return { check: { pattern, flags, hint: text(raw?.hint, 300) } };
+}
+
+// Validates and normalizes a tutorial payload from the agent. Returns
+// { tutorial } on success or { error } with a message the agent can act on.
+// A caller that JSON-encodes the tutorial sends a string where the schema
+// documents an object. The schema below accepts both so the request survives
+// long enough to get here, and this is where the string becomes the object the
+// rest of the publish path expects. Anything that is not a JSON object once
+// parsed is handed on untouched, so normalizeTutorial reports the real problem
+// rather than this function inventing one.
+function coerceTutorial(raw) {
+ if (typeof raw !== "string") return raw;
+ const trimmed = raw.trim();
+ if (!trimmed) return raw;
+ let parsed;
+ try { parsed = JSON.parse(trimmed); } catch { return raw; }
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : raw;
+}
+
+function normalizeTutorial(raw) {
+ if (typeof raw === "string") {
+ // Only reachable when the text failed to parse: coerceTutorial hands
+ // back anything it could not turn into an object. Naming that is worth
+ // a branch, because "must be an object" reads like a schema complaint
+ // about a payload the caller can see is a tutorial.
+ return { error: "The tutorial arrived as text that is not valid JSON. Send it as an object with title, steps, and exercise." };
+ }
+ if (!raw || typeof raw !== "object") {
+ return { error: "Tutorial payload must be an object with title, steps, and exercise." };
+ }
+
+ const title = text(raw.title, 160);
+ if (!title) return { error: "Tutorial needs a non-empty title." };
+ const summary = text(raw.summary, 1200);
+ const source = text(raw.source, 200);
+
+ const rawSteps = Array.isArray(raw.steps) ? raw.steps.slice(0, MAX_STEPS) : [];
+ if (!rawSteps.length) return { error: "Tutorial needs at least one step describing an edit." };
+
+ const steps = [];
+ for (let i = 0; i < rawSteps.length; i++) {
+ const s = rawSteps[i] || {};
+ const explanation = text(s.explanation, 4000);
+ if (!explanation) return { error: "Step " + (i + 1) + " needs an explanation of the edit." };
+
+ let quiz = null;
+ if (s.quiz && typeof s.quiz === "object") {
+ const question = text(s.quiz.question, 500);
+ // Positions are meaningful here: answerIndex points into this array.
+ // Dropping a blank option used to shift every later one down a slot, so
+ // ["", "correct", "wrong"] with answerIndex 1 ended up marking "wrong"
+ // as the right answer. Keep the positions and refuse the blank instead.
+ const options = (Array.isArray(s.quiz.options) ? s.quiz.options.slice(0, 5) : [])
+ .map((o) => text(o, 300));
+ if (options.some((o) => !o)) {
+ return {
+ error:
+ "Step " + (i + 1) + " quiz has a blank option. Every option needs text, because " +
+ "answerIndex refers to their positions.",
+ };
+ }
+ const answerIndex = Number.isInteger(s.quiz.answerIndex) ? s.quiz.answerIndex : -1;
+ if (question && options.length >= 2 && answerIndex >= 0 && answerIndex < options.length) {
+ quiz = { question, options, answerIndex, why: text(s.quiz.why, 800) };
+ }
+ }
+
+ steps.push({
+ id: "step-" + (i + 1),
+ file: text(s.file, 260),
+ heading: text(s.heading, 160) || "Step " + (i + 1),
+ explanation,
+ before: code(s.before),
+ after: code(s.after),
+ quiz,
+ });
+ }
+
+ const ex = raw.exercise;
+ if (!ex || typeof ex !== "object") {
+ return { error: "Tutorial needs an exercise object (brief, starterCode, solutionChecks)." };
+ }
+ const brief = text(ex.brief, 4000);
+ if (!brief) return { error: "Exercise needs a brief telling the learner what to build." };
+
+ const checks = [];
+ const rawChecks = Array.isArray(ex.solutionChecks) ? ex.solutionChecks.slice(0, MAX_CHECKS) : [];
+ for (const c of rawChecks) {
+ const result = normalizeCheck(c);
+ if (result.error) return { error: result.error };
+ if (result.check) checks.push(result.check);
+ }
+ if (!checks.length) {
+ return { error: "Exercise needs at least one solutionChecks entry ({ pattern, hint })." };
+ }
+
+ return {
+ tutorial: {
+ title,
+ summary,
+ source,
+ steps,
+ exercise: {
+ heading: text(ex.heading, 160) || "Your turn",
+ brief,
+ file: text(ex.file, 260),
+ starterCode: code(ex.starterCode),
+ hints: (Array.isArray(ex.hints) ? ex.hints : [])
+ .map((h) => text(h, 500))
+ .filter(Boolean)
+ .slice(0, MAX_HINTS),
+ checks,
+ solution: code(ex.solution),
+ },
+ },
+ };
+}
+
+function freshProgress(tutorial) {
+ const steps = {};
+ for (const s of tutorial?.steps || []) {
+ steps[s.id] = { understood: false, quizAnswer: null, quizCorrect: false };
+ }
+ return {
+ steps,
+ exercise: {
+ code: tutorial?.exercise?.starterCode || "",
+ attempts: 0,
+ failedAttempts: 0,
+ hintsRevealed: 0,
+ solutionRevealed: false,
+ completed: false,
+ completedBy: null,
+ completedAt: null,
+ // The exact code the checks or an approval passed. `code` keeps
+ // moving with the editor afterwards; this stays what was verified.
+ completedCode: null,
+ approvalNote: "",
+ },
+ startedAt: new Date().toISOString(),
+ };
+}
+
+function getState() {
+ if (!sessionState) {
+ sessionState = { tutorial: null, progress: null, archive: [], activePos: 0, rev: 0, progressSeq: 0 };
+ }
+ return sessionState;
+}
+
+// --- Lesson history ---
+
+// Publishing no longer discards the lesson on screen: it joins a short history
+// the learner can page through with arrows in the canvas header. The active
+// lesson stays in state.tutorial / state.progress exactly as before, so every
+// existing invariant (progress writes, approval digests, resets) keeps meaning
+// "the lesson on screen". The lessons the learner is not looking at wait in
+// state.archive, each with its own progress, and state.activePos records where
+// the active lesson sits in publish order.
+
+function activeLessonPos(state) {
+ if (!state.tutorial) return -1;
+ const archived = Array.isArray(state.archive) ? state.archive.length : 0;
+ const pos = Number.isInteger(state.activePos) ? state.activePos : archived;
+ return Math.max(0, Math.min(pos, archived));
+}
+
+// Identity of the lesson on screen, independent of where it sits in the history
+// and of the revision counter. Every progress write carries the id of the lesson
+// it was composed against, so a write still unsent when a publish archives that
+// lesson can be applied to the archived entry instead of being dropped: the
+// revision check alone cannot tell a write describing a lesson that no longer
+// exists from one describing the lesson now sitting in the history.
+function lessonId(state) {
+ if (typeof state.lessonId !== "string" || !state.lessonId) state.lessonId = randomUUID();
+ return state.lessonId;
+}
+
+// Every lesson in publish order, oldest first, with the active one in place.
+function lessonList(state) {
+ const list = Array.isArray(state.archive) ? state.archive.slice() : [];
+ if (state.tutorial) {
+ // The id and the write counter travel with the lesson into the archive,
+ // so a late write naming it lands on the right entry and is still
+ // ordered against the writes that entry had already accepted. The
+ // revision travels with them because the counter alone cannot order
+ // anything across a bump: it restarts at zero on every one, while the
+ // id survives an approval, a reset, and a round trip through the
+ // archive. Two writes to one lesson are comparable only inside the
+ // revision they were both composed against, which is the rule the
+ // active lesson already follows.
+ list.splice(activeLessonPos(state), 0, {
+ tutorial: state.tutorial,
+ progress: state.progress,
+ id: lessonId(state),
+ rev: state.rev || 0,
+ progressSeq: state.progressSeq || 0,
+ });
+ }
+ return list;
+}
+
+// Makes the lesson at `index` (into lessonList order) the one on screen.
+function activateLesson(state, index) {
+ const list = lessonList(state);
+ const chosen = list[index];
+ if (!chosen) return false;
+ list.splice(index, 1);
+ state.archive = list;
+ state.activePos = index;
+ state.tutorial = chosen.tutorial;
+ state.progress = chosen.progress || freshProgress(chosen.tutorial);
+ state.lessonId = typeof chosen.id === "string" && chosen.id ? chosen.id : randomUUID();
+ return true;
+}
+
+// A republish carrying the active lesson's title is a correction and replaces
+// it, which is what "republishing resets learner progress" always meant. A new
+// title is a new lesson: the current one keeps its place and its progress in
+// the history, and the new one starts at the end, active. The history is
+// capped; the oldest lesson falls off first.
+function publishLesson(state, tutorial) {
+ if (!state.tutorial || state.tutorial.title === tutorial.title) {
+ state.tutorial = tutorial;
+ state.progress = freshProgress(tutorial);
+ // A replacement is a different lesson as far as writes are concerned:
+ // its progress was just reset on purpose, so a write composed against
+ // the lesson this one replaces must not be applied to it.
+ state.lessonId = randomUUID();
+ return;
+ }
+ // The lesson being archived keeps the id it was published with (lessonList
+ // attached it), so writes still naming it can be routed to it afterwards.
+ const list = lessonList(state);
+ const id = randomUUID();
+ list.push({ tutorial, progress: freshProgress(tutorial), id, progressSeq: 0 });
+ while (list.length > MAX_LESSONS) list.shift();
+ state.archive = list.slice(0, -1);
+ state.activePos = state.archive.length;
+ state.tutorial = tutorial;
+ state.progress = freshProgress(tutorial);
+ state.lessonId = id;
+}
+
+// What the canvas receives: the active lesson plus just enough metadata to draw
+// lesson navigation. The archive can hold several full lessons, and the canvas
+// has no use for their bodies until one is switched to.
+function clientState(state) {
+ const list = lessonList(state);
+ return {
+ tutorial: state.tutorial,
+ progress: state.progress,
+ rev: state.rev || 0,
+ progressSeq: state.progressSeq || 0,
+ // Stamped on every progress write the canvas makes, so a write composed
+ // before a publish archived this lesson can still be routed to it.
+ lessonId: state.tutorial ? lessonId(state) : "",
+ lesson: {
+ index: Math.max(0, activeLessonPos(state)),
+ count: list.length,
+ titles: list.map((entry) => entry?.tutorial?.title || ""),
+ },
+ };
+}
+
+// Bumped by every authoritative lesson change: publishing, switching lessons,
+// approving, resetting. The canvas stamps each /progress body with the
+// revision it was composed against, so an update that was already in flight when
+// one of those landed is rejected instead of overwriting the newer state. Without
+// it a debounced progress save can silently undo an approval.
+function bumpRev(state) {
+ state.rev = (state.rev || 0) + 1;
+ // The sequence counts writes within one revision, so it restarts here. The
+ // canvas resets its own counter when it applies the new revision.
+ state.progressSeq = 0;
+ return state;
+}
+
+// --- Persistence ---
+
+// Saves come from HTTP handlers and from canvas actions, which can overlap, and
+// two concurrent writeFile calls to one path interleave their chunks: the file
+// ends up either holding the older snapshot or cut off mid-JSON, and loadState can
+// only treat a broken document as missing, so the lesson disappears. Writes are
+// queued per file and land through a rename, so a reader only ever sees a whole
+// document and the last save requested is the one that survives.
+const saveQueues = new Map(); // file path -> tail of that file's write chain
+
+async function atomicWrite(file, contents) {
+ const tmp = file + ".tmp-" + randomUUID();
+ try {
+ await writeFile(tmp, contents, "utf-8");
+ await rename(tmp, file);
+ } catch (error) {
+ try { await rm(tmp, { force: true }); } catch {}
+ throw error;
+ }
+}
+
+function queueWrite(file, contents) {
+ const run = async () => {
+ try { await mkdir(dirname(file), { recursive: true }); } catch {}
+ await atomicWrite(file, contents);
+ };
+ const prior = saveQueues.get(file) || Promise.resolve();
+ const chained = prior.then(run, run);
+ saveQueues.set(file, chained);
+ // Drop the entry once this write is the tail, so the map does not grow.
+ const cleanup = () => {
+ if (saveQueues.get(file) === chained) saveQueues.delete(file);
+ };
+ chained.then(cleanup, cleanup);
+ return chained;
+}
+
+function saveState(workspacePath, state) {
+ if (!workspacePath) return Promise.resolve();
+ const dir = join(workspacePath, "files");
+ // Serialize the snapshot now rather than when the write runs, so a queued save
+ // persists the state as it was when the save was asked for.
+ let contents;
+ try { contents = JSON.stringify(state, null, 2); } catch { return Promise.resolve(); }
+
+ // The rendered artifact rides along with every save, but only as best effort:
+ // the JSON file is what reopening the canvas restores from, so a rendering
+ // problem must never fail the save that protects the learner's progress.
+ if (state.tutorial) {
+ try {
+ queueWrite(join(dir, ARTIFACT_FILENAME), renderArtifactHtml(state)).catch(() => {});
+ } catch {}
+ }
+ return queueWrite(join(dir, STATE_FILENAME), contents);
+}
+
+// A state file written before the backtracking screen existed, or edited by hand,
+// can carry patterns the publish path would now refuse. Drop those on the way back
+// in rather than handing them to the canvas. An exercise left with no runnable
+// checks is still completable through "Ask Copilot for a review".
+function rescreenExercise(tutorial) {
+ const ex = tutorial?.exercise;
+ if (!ex || typeof ex !== "object") return;
+ // Checks always end up an array the canvas can take .length of: a loaded
+ // exercise without one gets the empty set, which the canvas already
+ // handles as "ask Copilot for a review instead".
+ ex.checks = (Array.isArray(ex.checks) ? ex.checks.slice(0, MAX_CHECKS) : [])
+ .map((c) => normalizeCheck(c))
+ .filter((r) => !r.error && r.check)
+ .map((r) => r.check);
+}
+
+// Loaded state comes from disk, and the artifact path accepts the embedded
+// block from any html file in the workspace, so none of it gets the benefit of
+// the doubt on size: every field goes back under the caps the publish path
+// enforces. Content is clamped, never rejected, so a lesson that was legal
+// when it was saved always comes back whole.
+function clampLoadedLesson(tutorial) {
+ if (!tutorial || typeof tutorial !== "object") return;
+ tutorial.title = text(tutorial.title, 160);
+ tutorial.summary = text(tutorial.summary, 1200);
+ tutorial.source = text(tutorial.source, 200);
+ tutorial.steps = (Array.isArray(tutorial.steps) ? tutorial.steps : [])
+ .filter((step) => step && typeof step === "object")
+ .slice(0, MAX_STEPS);
+ tutorial.steps.forEach((step, index) => {
+ // Ids are regenerated, never trusted: the canvas page interpolates step
+ // ids into inline handlers, and the publish path only ever writes these
+ // positional ids, so for any legitimately saved lesson this is the
+ // identity (progress keys keep matching). A crafted id from a tampered
+ // file dies here instead of reaching the page.
+ step.id = "step-" + (index + 1);
+ step.file = text(step.file, 260);
+ step.heading = text(step.heading, 160);
+ step.explanation = text(step.explanation, 4000);
+ step.before = code(step.before);
+ step.after = code(step.after);
+ if (step.quiz && typeof step.quiz === "object") {
+ step.quiz.question = text(step.quiz.question, 500);
+ // Positions stay meaningful for answerIndex, so options are
+ // clamped in place, never filtered.
+ step.quiz.options = (Array.isArray(step.quiz.options) ? step.quiz.options : [])
+ .slice(0, 5)
+ .map((option) => text(option, 300));
+ step.quiz.why = text(step.quiz.why, 800);
+ }
+ });
+ const ex = tutorial.exercise;
+ if (ex && typeof ex === "object") {
+ ex.heading = text(ex.heading, 160);
+ ex.brief = text(ex.brief, 4000);
+ ex.file = text(ex.file, 260);
+ ex.starterCode = code(ex.starterCode);
+ ex.solution = code(ex.solution);
+ ex.hints = (Array.isArray(ex.hints) ? ex.hints : [])
+ .slice(0, MAX_HINTS)
+ .map((hint) => text(hint, 500));
+ }
+}
+
+// A lesson is renderable only with at least one step and an exercise object.
+// Every lesson the publish path saves has both (normalizeTutorial refuses
+// anything less), so requiring them here is the identity for legitimate data
+// and a refusal for a malformed block, which would otherwise crash the canvas
+// renderer mid-restore and leave it stuck on the loading screen.
+function renderableLesson(tutorial) {
+ return !!(tutorial && typeof tutorial === "object" &&
+ Array.isArray(tutorial.steps) && tutorial.steps.length > 0 &&
+ tutorial.exercise && typeof tutorial.exercise === "object");
+}
+
+// Progress the canvas can index into; anything else becomes null so the
+// callers' freshProgress fallbacks take over instead of the renderer throwing.
+function structuredProgress(progress) {
+ return progress && typeof progress === "object" &&
+ progress.steps && typeof progress.steps === "object" &&
+ progress.exercise && typeof progress.exercise === "object"
+ ? progress
+ : null;
+}
+
+function clampLoadedState(state) {
+ clampLoadedLesson(state?.tutorial);
+ state.archive = (Array.isArray(state?.archive) ? state.archive : []).slice(0, MAX_LESSONS);
+ for (const entry of state.archive) clampLoadedLesson(entry?.tutorial);
+ if (!renderableLesson(state.tutorial)) {
+ state.tutorial = null;
+ state.progress = null;
+ }
+ state.archive = state.archive.filter((entry) => entry && renderableLesson(entry.tutorial));
+ for (const entry of state.archive) entry.progress = structuredProgress(entry.progress);
+ // Lesson ids come off disk like everything else, so they are clamped to a
+ // short string, and a lesson saved before ids existed is given one here
+ // rather than coming back unaddressable by a late write.
+ state.lessonId = text(state.lessonId, 80) || randomUUID();
+ for (const entry of state.archive) {
+ entry.id = text(entry.id, 80) || randomUUID();
+ const entryRev = Number(entry.rev);
+ entry.rev = Number.isFinite(entryRev) && entryRev > 0 ? Math.floor(entryRev) : 0;
+ const seq = Number(entry.progressSeq);
+ entry.progressSeq = Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0;
+ }
+ state.progress = structuredProgress(state.progress);
+ const pe = state?.progress?.exercise;
+ if (pe && typeof pe === "object") {
+ // The attempt is learner text and can legitimately outgrow the snippet
+ // cap, so it gets the progress route's byte budget instead.
+ if (typeof pe.code === "string") pe.code = pe.code.slice(0, MAX_BODY_BYTES);
+ if (typeof pe.completedCode === "string") pe.completedCode = pe.completedCode.slice(0, MAX_BODY_BYTES);
+ pe.approvalNote = text(pe.approvalNote, 300);
+ }
+ return state;
+}
+
+// Archived lessons count too: any of them is one arrow press from the canvas.
+function rescreenLoadedChecks(state) {
+ rescreenExercise(state?.tutorial);
+ for (const entry of (Array.isArray(state?.archive) ? state.archive : [])) {
+ rescreenExercise(entry?.tutorial);
+ }
+ return state;
+}
+
+// The artifact embeds the full state document in a non-executing JSON block
+// precisely so the lesson can be rebuilt when the state file is gone. A chat
+// resumes the same session, so the JSON file is simply found again on reopen;
+// a project resumes into a fresh session whose workspace holds only what the
+// app preserved from the conversation, which is the rendered artifact.
+// Recovering from it is what keeps the canvas alive there. The app may copy
+// the preserved file back under another name, so any html file in the
+// workspace that carries the embedded state block counts, with the canonical
+// name tried first and the search bounded so a large workspace cannot stall
+// the open.
+const ARTIFACT_STATE_RE = /
+