feat: Plan Canvas, a browser review canvas for plans (#2467)

* feat: add Plan Canvas - browser annotate-and-approve review for plan artifacts

- scripts/plan-canvas.js CLI (open/await/end/stop/server; bin ecc-plan-canvas)
- loopback server + ECC-styled chrome + annotation SDK + zero-dep markdown renderer
- Approve/Request-changes verdicts wired to the /plan confirmation gate
- plan-canvas skill, /plan-canvas command, SessionStart hook surfacing open reviews
- shared scripts/lib/loopback-guard.js extracted from control-pane (API re-exported)
- 121 new tests incl. full-workflow E2E; registered in manifests, catalog, registry

Inspired by lavish-axi (https://github.com/kunchenguid/lavish-axi) by @kunchenguid;
original ECC-native implementation, not a port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(plan-canvas): invoke via ecc-plan-canvas bin so the skill works from any project

Skill/command referenced a cwd-relative `node scripts/plan-canvas.js`, unusable
outside the ECC root. Switch to the ecc-plan-canvas bin (and $CLAUDE_PLUGIN_ROOT
fallback) and align CLI next_step hints so an agent can run it as a skill in any repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plan-canvas): render Mermaid diagrams + ship Codex cross-harness surface

- markdown renderer emits <pre class="mermaid"> for ```mermaid blocks (source
  entity-escaped so the browser decodes it for the renderer while blocking injection)
- artifact template loads a pinned Mermaid build only when a diagram is present,
  themed to ECC dark, securityLevel strict, graceful offline fallback to source
  (ECC_PLAN_CANVAS_MERMAID_URL overrides for a local mirror)
- skill teaches Mermaid-for-diagrams and states the CLI+JSON loop is harness-agnostic
- add .agents/skills/plan-canvas (Codex) with agents/openai.yaml interface manifest
- register in install-modules workflow-quality paths; docs updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plan-canvas): add demo screenshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): sync yarn.lock with new bin; add contributor checklist

- yarn.lock records the ecc-plan-canvas bin so Yarn hardened-mode install no
  longer wants to modify the lockfile on public PRs
- PR template + CONTRIBUTING gain a pre-push checklist covering the lockfile
  trap and the full skill/command/CLI registration surfaces

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Haley Chen <2022hachen@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
haelyra 2026-07-08 17:12:48 -04:00 committed by GitHub
parent 4130457d67
commit a511395613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 4398 additions and 60 deletions

View file

@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Plan Canvas open-session surfacing (SessionStart)
*
* Cross-platform (Windows, macOS, Linux)
*
* If a Plan Canvas review is still open from a previous agent session,
* surface it at session start so a fresh session can resume the loop with
* `plan-canvas await <file>` instead of leaving the human talking to an
* empty chair in the browser.
*
* Never blocks: exits 0 on every error, prints nothing when there is
* nothing to resume.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function stateDir() {
const override = process.env.ECC_PLAN_CANVAS_STATE_DIR;
if (override && override.trim()) return path.resolve(override.trim());
return path.join(os.homedir(), '.claude', 'plan-canvas');
}
function openSessions() {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(stateDir(), 'sessions.json'), 'utf8'));
return Object.values(parsed.sessions || {}).filter(session => session.status !== 'ended');
} catch {
return [];
}
}
function buildContext(sessions) {
const lines = [
'[PlanCanvas] Open browser review sessions from a previous run:'
];
for (const session of sessions.slice(0, 5)) {
const pending = session.pendingFeedback && session.pendingFeedback.length;
lines.push(` - ${session.file}${pending ? ` (${pending} undelivered feedback item${pending === 1 ? '' : 's'})` : ''}`);
}
lines.push(
'Resume with `node scripts/plan-canvas.js await <file>` (plan-canvas skill), or `end <file>` if the review is obsolete.'
);
return lines.join('\n');
}
function run() {
const sessions = openSessions();
if (sessions.length > 0) {
process.stdout.write(`${buildContext(sessions)}\n`);
}
return 0;
}
if (require.main === module) {
try {
process.exit(run());
} catch (error) {
process.stderr.write(`[PlanCanvas] WARNING: ${error.message}\n`);
process.exit(0);
}
}
module.exports = { run, openSessions, buildContext };

View file

@ -24,42 +24,14 @@ async function withStateStore(stateDbPath, fn) {
}
}
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
// Extract the hostname portion of an HTTP Host header value, stripping any
// port. Returns null when the header is missing or malformed. Used to gate
// requests against a local-only allowlist so DNS-rebinding cannot pivot a
// browser tab into the loopback control-pane API.
function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
if (!match) return null;
return match[1].toLowerCase();
}
function buildAllowedHostnames(configuredHost) {
const set = new Set(LOOPBACK_HOSTNAMES);
if (configuredHost) set.add(String(configuredHost).toLowerCase());
return set;
}
function isAllowedHostHeader(hostHeader, allowedHostnames) {
const hostname = parseHostHeader(hostHeader);
if (!hostname) return false;
return allowedHostnames.has(hostname);
}
function isAllowedOrigin(originHeader, allowedHostnames) {
if (!originHeader || typeof originHeader !== 'string') return true;
try {
const url = new URL(originHeader);
return allowedHostnames.has(url.hostname.toLowerCase());
} catch {
return false;
}
}
// Host/Origin gating lives in scripts/lib/loopback-guard.js so every ECC
// loopback server shares one hardened implementation; re-exported below to
// keep this module's public API stable.
const {
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin
} = require('../loopback-guard');
function usage() {
return [

View file

@ -0,0 +1,53 @@
'use strict';
/**
* Host/Origin gating for ECC's loopback HTTP servers (control pane, plan
* canvas). DNS rebinding can point an attacker-controlled hostname at
* 127.0.0.1, so every request must present a Host header from this
* allowlist before the server does any work.
*/
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
// Extract the hostname portion of an HTTP Host header value, stripping any
// port. Returns null when the header is missing or malformed.
function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
if (!match) return null;
return match[1].toLowerCase();
}
function buildAllowedHostnames(configuredHost) {
const set = new Set(LOOPBACK_HOSTNAMES);
if (configuredHost) set.add(String(configuredHost).toLowerCase());
return set;
}
function isAllowedHostHeader(hostHeader, allowedHostnames) {
const hostname = parseHostHeader(hostHeader);
if (!hostname) return false;
return allowedHostnames.has(hostname);
}
// Origin is absent on same-origin navigations and CLI clients; when present
// it must resolve to an allowed hostname.
function isAllowedOrigin(originHeader, allowedHostnames) {
if (!originHeader || typeof originHeader !== 'string') return true;
try {
const url = new URL(originHeader);
return allowedHostnames.has(url.hostname.toLowerCase());
} catch {
return false;
}
}
module.exports = {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin,
parseHostHeader
};

View file

@ -0,0 +1,277 @@
'use strict';
/**
* Minimal GitHub-flavored-markdown subset renderer for Plan Canvas.
* Renders .claude/plans/*.plan.md artifacts to HTML body content.
*
* Security model: the entire source line is HTML-escaped before any inline
* rule runs, so raw HTML in the markdown always displays as text. Link and
* image URLs are validated against an allowlist of protocols.
*/
// Placeholders live in the Unicode private-use area so escaped output can
// never collide with them. Pre-existing occurrences are stripped from input.
const TOKEN_OPEN = '\uE000';
const TOKEN_CLOSE = '\uE001';
const TOKEN_RE = new RegExp(TOKEN_OPEN + '(\\d+)' + TOKEN_CLOSE, 'g');
const STRIP_RE = new RegExp('[' + TOKEN_OPEN + TOKEN_CLOSE + ']', 'g');
const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
const HR_RE = /^ {0,3}(-{3,}|\*{3,})\s*$/;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function slugify(text) {
return String(text ?? '')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/[\s-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Strip whitespace/control characters so "Ja vaScript:" style tricks cannot
// hide a scheme, then classify against the allowlist.
function classifyUrl(rawUrl) {
const compact = String(rawUrl)
.split('')
.filter((ch) => ch.charCodeAt(0) > 32)
.join('')
.toLowerCase();
if (compact.startsWith('#')) return 'anchor';
if (compact.startsWith('//')) return 'blocked';
const scheme = compact.match(/^[a-z][a-z0-9+.-]*:/);
if (!scheme) return 'relative';
if (scheme[0] === 'http:' || scheme[0] === 'https:') return 'http';
if (scheme[0] === 'mailto:') return 'mailto';
return 'blocked';
}
function applyEmphasis(s) {
return s
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
}
function renderInline(rawText) {
const tokens = [];
const stash = (html) => {
tokens.push(html);
return TOKEN_OPEN + (tokens.length - 1) + TOKEN_CLOSE;
};
let s = escapeHtml(rawText);
// Code spans first: contents stay escaped and opt out of all other rules.
s = s.replace(/`([^`]+)`/g, (_m, code) => stash('<code>' + code + '</code>'));
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
const kind = classifyUrl(src);
if (kind !== 'http' && kind !== 'relative') return alt;
return stash('<img src="' + src.trim() + '" alt="' + alt + '">');
});
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, label, url) => {
const kind = classifyUrl(url);
const text = applyEmphasis(label);
if (kind === 'blocked') return text;
const extra = kind === 'http' ? ' target="_blank" rel="noopener"' : '';
return stash('<a href="' + url.trim() + '"' + extra + '>' + text + '</a>');
});
s = applyEmphasis(s);
// Stashed anchors may hold code-span tokens, so resolve until none remain.
while (s.includes(TOKEN_OPEN)) {
s = s.replace(TOKEN_RE, (_m, idx) => tokens[Number(idx)]);
}
return s;
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
return s
.replace(/\\\|/g, TOKEN_OPEN)
.split('|')
.map((cell) => cell.split(TOKEN_OPEN).join('|').trim());
}
function isAlignmentRow(line) {
if (!line || !line.includes('|')) return false;
const cells = splitTableRow(line);
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
}
function cellAlign(spec) {
const left = spec.startsWith(':');
const right = spec.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return '';
}
function renderListItem(text) {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
const checked = task[1].trim() ? ' checked' : '';
return '<li class="task"><input type="checkbox" disabled' + checked + '> ' +
renderInline(task[2]) + '</li>';
}
return '<li>' + renderInline(text) + '</li>';
}
function buildList(items, start, indent) {
const tag = /^\d/.test(items[start].marker) ? 'ol' : 'ul';
const parts = [];
let i = start;
while (i < items.length && items[i].indent >= indent) {
if (items[i].indent > indent) {
// Deeper item: nest a sublist inside the previous <li>
const nested = buildList(items, i, items[i].indent);
if (parts.length > 0) {
const last = parts.pop();
parts.push(last.replace(/<\/li>$/, '\n' + nested.html + '\n</li>'));
} else {
parts.push('<li>\n' + nested.html + '\n</li>');
}
i = nested.end;
} else {
parts.push(renderListItem(items[i].text));
i += 1;
}
}
return { html: '<' + tag + '>\n' + parts.join('\n') + '\n</' + tag + '>', end: i };
}
function startsBlock(line, nextLine) {
return /^```/.test(line) ||
/^#{1,6}\s/.test(line) ||
HR_RE.test(line) ||
/^ {0,3}>/.test(line) ||
LIST_ITEM_RE.test(line) ||
(line.includes('|') && isAlignmentRow(nextLine || ''));
}
function renderMarkdown(text) {
if (!text) return '';
const lines = String(text)
.replace(STRIP_RE, '')
.replace(/\r\n?/g, '\n')
.split('\n');
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i += 1;
continue;
}
const fence = line.match(/^```(.*)$/);
if (fence) {
const lang = fence[1].trim().split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9-]/g, '');
const body = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
body.push(lines[i]);
i += 1;
}
i += 1; // skip closing fence (or run off EOF)
if (lang === 'mermaid') {
// Mermaid reads the element's textContent, and the browser decodes
// character references there — so escaping keeps `-->`/`<` intact for
// the renderer while preventing HTML injection or a </pre> breakout.
out.push('<pre class="mermaid">' + escapeHtml(body.join('\n')) + '</pre>');
continue;
}
const cls = lang ? ' class="language-' + lang + '"' : '';
out.push('<pre><code' + cls + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
if (heading) {
const level = heading[1].length;
out.push('<h' + level + ' id="' + slugify(heading[2]) + '">' +
renderInline(heading[2]) + '</h' + level + '>');
i += 1;
continue;
}
// Horizontal rule (alignment rows never reach here: tables consume them)
if (HR_RE.test(line)) {
out.push('<hr>');
i += 1;
continue;
}
// Blockquote: strip one `>` level and recurse, which handles nesting
if (/^ {0,3}>/.test(line)) {
const inner = [];
while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
inner.push(lines[i].replace(/^ {0,3}> ?/, ''));
i += 1;
}
out.push('<blockquote>\n' + renderMarkdown(inner.join('\n')) + '\n</blockquote>');
continue;
}
// Table: header row followed by an alignment row
if (line.includes('|') && isAlignmentRow(lines[i + 1] || '')) {
const aligns = splitTableRow(lines[i + 1]).map(cellAlign);
const row = (tag, cells) => '<tr>' + cells.map((cell, idx) => {
const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
return '<' + tag + style + '>' + renderInline(cell) + '</' + tag + '>';
}).join('') + '</tr>';
const head = row('th', splitTableRow(line));
const body = [];
i += 2;
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
body.push(row('td', splitTableRow(lines[i])));
i += 1;
}
out.push('<table>\n<thead>\n' + head + '\n</thead>\n<tbody>\n' +
body.join('\n') + '\n</tbody>\n</table>');
continue;
}
if (LIST_ITEM_RE.test(line)) {
const items = [];
while (i < lines.length) {
const m = lines[i].match(LIST_ITEM_RE);
if (!m) break;
items.push({ indent: m[1].length, marker: m[2], text: m[3] });
i += 1;
}
out.push(buildList(items, 0, items[0].indent).html);
continue;
}
// Paragraph: run of plain lines up to a blank line or block start
const para = [line.trim()];
i += 1;
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
para.push(lines[i].trim());
i += 1;
}
out.push('<p>' + renderInline(para.join('\n')) + '</p>');
}
return out.join('\n');
}
module.exports = { renderMarkdown, escapeHtml, slugify };

View file

@ -0,0 +1,237 @@
'use strict';
/**
* Plan Canvas artifact SDK the script injected into the reviewed artifact.
*
* The artifact runs in a sandboxed iframe without allow-same-origin, so this
* script can only talk to the chrome via postMessage. It renders all of its
* own UI inside a shadow root so it never annotates itself and never leaks
* styles into the artifact.
*/
function artifactSdkJs() {
return `'use strict';
(() => {
if (window.parent === window) return; // only meaningful inside the canvas
if (window.__eccPlanCanvasSdk) return;
window.__eccPlanCanvasSdk = true;
let annotate = true;
let card = null;
const post = msg => window.parent.postMessage(msg, '*');
// --- shadow-root UI host --------------------------------------------
const host = document.createElement('div');
host.setAttribute('data-ecc-plan-canvas', 'ui');
host.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = \`
<style>
:host{all:initial}
.hl{position:fixed;pointer-events:none;border:1.5px solid #6885e8;background:rgba(104,133,232,0.12);border-radius:4px;display:none;z-index:2147483646;transition:all .06s ease-out}
.selhint{position:absolute;display:none;z-index:2147483647;background:#101218;color:#dfe2e9;border:1px solid #272c3e;border-radius:6px;padding:4px 10px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer;box-shadow:0 8px 32px rgba(0,0,0,0.6)}
.selhint:hover{border-color:#6885e8}
.card{position:absolute;display:none;z-index:2147483647;width:300px;background:#101218;border:1px solid #272c3e;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#dfe2e9}
.card h4{margin:0;padding:10px 12px 0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#80859a}
.card .snippet{padding:4px 12px 0;font:10.5px 'SF Mono','Fira Code',monospace;color:#4acbbe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card textarea{display:block;width:calc(100% - 24px);margin:8px 12px;min-height:56px;resize:vertical;background:#13161e;border:1px solid #1d2130;border-radius:6px;color:#dfe2e9;font:12.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:7px 9px;outline:none;box-sizing:border-box}
.card textarea:focus{border-color:#6885e8}
.card .row{display:flex;justify-content:flex-end;gap:8px;padding:0 12px 12px}
.card button{border-radius:6px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:5px 12px;cursor:pointer}
.card .cancel{background:none;border:1px solid #1d2130;color:#80859a}
.card .cancel:hover{color:#dfe2e9;border-color:#272c3e}
.card .queue{background:#6885e8;border:1px solid #6885e8;color:#fff}
.card .queue:hover{background:#3d5ab8}
.card .keys{padding:0 12px 10px;font-size:9.5px;color:#4c5168}
</style>
<div class="hl"></div>
<button class="selhint" type="button">Annotate selection</button>
<div class="card">
<h4></h4>
<div class="snippet"></div>
<textarea placeholder="What should change here?"></textarea>
<div class="row">
<button class="cancel" type="button">Cancel</button>
<button class="queue" type="button">Queue</button>
</div>
<div class="keys">Enter to queue &middot; Cmd/Ctrl+Enter to queue &amp; send</div>
</div>\`;
const attach = () => document.body ? document.body.appendChild(host) : null;
if (document.body) attach();
else document.addEventListener('DOMContentLoaded', attach);
const hl = root.querySelector('.hl');
const selhint = root.querySelector('.selhint');
const cardEl = root.querySelector('.card');
const cardTitle = cardEl.querySelector('h4');
const cardSnippet = cardEl.querySelector('.snippet');
const cardText = cardEl.querySelector('textarea');
// --- selectors & context ---------------------------------------------
const esc = v => (window.CSS && CSS.escape) ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
function selectorFor(el) {
const parts = [];
let node = el;
for (let depth = 0; node && node.nodeType === 1 && depth < 6; depth++) {
if (node.id) { parts.unshift('#' + esc(node.id)); return parts.join(' > '); }
const tag = node.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') { parts.unshift(tag); break; }
let nth = 1;
let sib = node;
while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) nth++;
parts.unshift(tag + ':nth-of-type(' + nth + ')');
node = node.parentElement;
}
return parts.join(' > ');
}
function snippetFor(el) {
return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200);
}
const INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'option', 'label', 'summary', 'a']);
function isInteractive(el) {
let node = el;
while (node && node.nodeType === 1) {
if (INTERACTIVE.has(node.tagName.toLowerCase()) || node.isContentEditable) return true;
node = node.parentElement;
}
return false;
}
const isOurs = el => el === host || host.contains(el);
// --- annotation card ---------------------------------------------------
function openCard(target) {
card = target;
cardTitle.textContent = target.kindLabel;
cardSnippet.textContent = target.anchor.snippet || target.anchor.selector;
cardText.value = '';
cardEl.style.display = 'block';
const x = Math.min(target.x, window.innerWidth - 320) + window.scrollX;
const y = target.y + 12 + window.scrollY;
cardEl.style.left = Math.max(8, x) + 'px';
cardEl.style.top = y + 'px';
cardText.focus();
}
function closeCard() {
card = null;
cardEl.style.display = 'none';
}
function queueCard(sendNow) {
if (!card) return;
const text = cardText.value.trim();
if (!text) { cardText.focus(); return; }
post({
type: sendNow ? 'pc:queue-and-send' : 'pc:queue',
item: { kind: 'annotation', text, anchor: card.anchor }
});
closeCard();
}
cardEl.querySelector('.cancel').addEventListener('click', closeCard);
cardEl.querySelector('.queue').addEventListener('click', () => queueCard(false));
cardText.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); queueCard(true); }
else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); queueCard(false); }
else if (e.key === 'Escape') closeCard();
});
// --- element hover / click ---------------------------------------------
document.addEventListener('mousemove', e => {
if (!annotate || card) { hl.style.display = 'none'; return; }
const el = e.target;
if (!el || isOurs(el) || el === document.body || el === document.documentElement || isInteractive(el)) {
hl.style.display = 'none';
return;
}
const rect = el.getBoundingClientRect();
hl.style.display = 'block';
hl.style.left = rect.left - 2 + 'px';
hl.style.top = rect.top - 2 + 'px';
hl.style.width = rect.width + 'px';
hl.style.height = rect.height + 'px';
}, true);
document.addEventListener('click', e => {
if (!annotate) return;
const el = e.target;
if (isOurs(el)) return;
if (card) { if (!cardEl.contains(e.composedPath()[0])) closeCard(); return; }
if (isInteractive(el)) return; // let controls behave natively
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return; // handled by selection flow
if (el === document.body || el === document.documentElement) return;
e.preventDefault();
e.stopPropagation();
hl.style.display = 'none';
openCard({
kindLabel: 'Annotate <' + el.tagName.toLowerCase() + '>',
anchor: { selector: selectorFor(el), tag: el.tagName.toLowerCase(), snippet: snippetFor(el) },
x: e.clientX,
y: e.clientY
});
}, true);
// --- text selection -------------------------------------------------------
document.addEventListener('mouseup', e => {
if (!annotate || card || isOurs(e.target)) return;
setTimeout(() => {
const selection = window.getSelection();
const text = selection ? String(selection).replace(/\\s+/g, ' ').trim() : '';
if (!text || !selection.rangeCount) { selhint.style.display = 'none'; return; }
const rect = selection.getRangeAt(0).getBoundingClientRect();
selhint.style.display = 'block';
selhint.style.left = rect.left + window.scrollX + 'px';
selhint.style.top = rect.bottom + 6 + window.scrollY + 'px';
selhint.onclick = () => {
selhint.style.display = 'none';
const anchorNode = selection.anchorNode;
const el = anchorNode && anchorNode.nodeType === 1 ? anchorNode : anchorNode && anchorNode.parentElement;
openCard({
kindLabel: 'Annotate selection',
anchor: {
selector: el ? selectorFor(el) : 'body',
tag: 'text',
snippet: text.slice(0, 200),
textRange: { text: text.slice(0, 1000) }
},
x: rect.left,
y: rect.bottom
});
};
}, 0);
}, true);
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) selhint.style.display = 'none';
});
// --- chrome bridge ---------------------------------------------------------
window.addEventListener('message', e => {
const msg = e.data || {};
if (msg.type === 'pc:set-mode') {
annotate = Boolean(msg.annotate);
if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); }
} else if (msg.type === 'pc:restore-scroll') {
window.scrollTo(msg.x || 0, msg.y || 0);
}
});
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
post({ type: 'pc:toggle-mode' });
} else if (e.key === 'Escape' && card) closeCard();
}, true);
let scrollTimer = null;
window.addEventListener('scroll', () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
scrollTimer = null;
post({ type: 'pc:scroll', x: window.scrollX, y: window.scrollY });
}, 150);
}, { passive: true });
post({ type: 'pc:ready' });
})();`;
}
module.exports = { artifactSdkJs };

View file

@ -0,0 +1,532 @@
'use strict';
/**
* Plan Canvas loopback server.
*
* One detached process serves every open review session: the browser chrome,
* the rendered artifact, an SSE stream for live updates, and the long-poll
* endpoint agents block on. Sessions are keyed by canonical artifact path
* (see sessions.js).
*/
const { EventEmitter } = require('events');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
const { renderMarkdown } = require('./markdown');
const { artifactSdkJs } = require('./sdk');
const {
canvasCss,
canvasClientJs,
renderCanvasHtml,
renderMarkdownArtifactHtml,
renderSessionListHtml
} = require('./ui');
const DEFAULT_PORT = 4517;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const MAX_BODY_BYTES = 1024 * 1024;
const CONTENT_TYPES = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
function resolvePort(env = process.env) {
const value = Number.parseInt(env.ECC_PLAN_CANVAS_PORT || '', 10);
return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT;
}
function resolveIdleTimeoutMs(env = process.env) {
const raw = String(env.ECC_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase();
if (raw === '0' || raw === 'off') return 0;
const value = Number.parseInt(raw, 10);
return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', chunk => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error('body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (chunks.length === 0) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('invalid JSON body'));
}
});
req.on('error', reject);
});
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(body);
}
function sendHtml(res, statusCode, html, { csp = true } = {}) {
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
if (csp) {
headers['content-security-policy'] =
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
}
res.writeHead(statusCode, headers);
res.end(html);
}
function createPlanCanvasServer({
store,
host = DEFAULT_HOST,
version = '0.0.0',
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
heartbeatMs = 15000,
onIdleShutdown = null,
log = () => {}
} = {}) {
if (!store) throw new Error('createPlanCanvasServer requires a session store');
const allowedHostnames = buildAllowedHostnames(host);
const wake = new EventEmitter();
wake.setMaxListeners(0);
const sseClients = new Map(); // key -> Set<res>
const awaitCounts = new Map(); // key -> active long-poll count
const workingKeys = new Set(); // keys whose agent took feedback and is off working
const watchers = new Map(); // key -> fs.FSWatcher
let idleTimer = null;
let closed = false;
// --- presence + SSE ---------------------------------------------------
function presenceFor(key) {
const session = store.get(key);
if (!session || session.status === 'ended') return 'ended';
if ((awaitCounts.get(key) || 0) > 0) return 'listening';
return workingKeys.has(key) ? 'working' : 'waiting';
}
function broadcast(key, event, payload) {
const clients = sseClients.get(key);
if (!clients) return;
const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of clients) client.write(frameText);
}
function broadcastPresence(key) {
broadcast(key, 'presence', { state: presenceFor(key) });
}
function connectionCount() {
let total = 0;
for (const clients of sseClients.values()) total += clients.size;
for (const count of awaitCounts.values()) total += count;
return total;
}
function armIdleTimer() {
if (!idleTimeoutMs || closed) return;
if (connectionCount() > 0) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (connectionCount() === 0 && !closed) {
log('[plan-canvas] idle timeout reached, shutting down');
if (onIdleShutdown) onIdleShutdown();
}
}, idleTimeoutMs);
if (idleTimer.unref) idleTimer.unref();
}
function noteConnectionOpened() {
clearTimeout(idleTimer);
}
function noteConnectionClosed() {
armIdleTimer();
}
// --- artifact watching --------------------------------------------------
function watchSession(session) {
if (watchers.has(session.key)) return;
const dir = path.dirname(session.file);
const base = path.basename(session.file);
let debounce = null;
try {
const watcher = fs.watch(dir, (eventType, filename) => {
if (filename && filename !== base) return;
clearTimeout(debounce);
debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
});
watcher.on('error', () => watchers.delete(session.key));
watchers.set(session.key, watcher);
} catch {
// Watching is best-effort; manual reload still works.
}
}
function unwatchSession(key) {
const watcher = watchers.get(key);
if (watcher) {
watcher.close();
watchers.delete(key);
}
}
// --- session actions ------------------------------------------------------
function endSession(key, endedBy) {
const session = store.end(key, endedBy);
if (!session) return null;
wake.emit(`wake:${key}`);
broadcast(key, 'ended', { endedBy: session.endedBy });
broadcastPresence(key);
unwatchSession(key);
return session;
}
// --- request handlers -------------------------------------------------------
async function handleApi(req, res, url) {
const { pathname } = url;
if (req.method === 'POST' && pathname === '/api/sessions') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
if (!fs.existsSync(path.resolve(body.file))) {
return sendJson(res, 404, { error: `artifact not found: ${body.file}` });
}
const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) });
if (refused) {
return sendJson(res, 409, {
status: 'user-ended',
key: session.key,
next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
});
}
watchSession(session);
broadcastPresence(session.key);
return sendJson(res, 200, {
status: 'open',
key: session.key,
file: session.file,
url: `/canvas/${session.key}`
});
}
if (req.method === 'GET' && pathname === '/api/sessions') {
return sendJson(res, 200, { sessions: store.list() });
}
if (req.method === 'GET' && pathname === '/api/await') {
const file = url.searchParams.get('file');
if (!file) return sendJson(res, 400, { error: 'file query parameter is required' });
const session = store.findByFile(file);
if (!session) return sendJson(res, 200, { status: 'missing' });
const key = session.key;
const timeoutRaw = url.searchParams.get('timeoutMs');
const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0);
const first = store.takeFeedback(key);
if (first.status !== 'waiting') {
if (first.status === 'feedback') workingKeys.add(key);
broadcastPresence(key);
return sendJson(res, 200, first);
}
// Long poll: hold the request open until feedback or session end.
noteConnectionOpened();
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
workingKeys.delete(key);
broadcastPresence(key);
let settled = false;
let heartbeat = null;
let waitTimer = null;
const finish = payload => {
if (settled) return;
settled = true;
cleanup();
if (payload) {
if (payload.status === 'feedback') workingKeys.add(key);
res.end(JSON.stringify(payload));
}
broadcastPresence(key);
noteConnectionClosed();
};
const onWake = () => {
const result = store.takeFeedback(key);
if (result.status !== 'waiting') finish(result);
};
// Settle held polls on shutdown so server.close() can complete; the
// CLI tells agents to simply re-run await.
const onServerClose = () =>
finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' });
const cleanup = () => {
wake.removeListener(`wake:${key}`, onWake);
wake.removeListener('server-close', onServerClose);
clearInterval(heartbeat);
clearTimeout(waitTimer);
awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1));
};
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
// Leading whitespace keeps the connection visibly alive without
// corrupting the JSON payload written at the end.
res.write(' ');
heartbeat = setInterval(() => {
if (!settled) res.write(' ');
}, heartbeatMs);
if (timeoutMs !== null) {
waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs);
}
wake.on(`wake:${key}`, onWake);
wake.once('server-close', onServerClose);
req.on('close', () => finish(null));
return undefined;
}
if (req.method === 'POST' && pathname === '/api/end') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
const session = store.findByFile(body.file);
if (!session) return sendJson(res, 404, { error: 'no session for that file' });
endSession(session.key, 'agent');
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
}
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/);
if (sessionMatch && req.method === 'POST') {
const [, key, action] = sessionMatch;
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
if (action === 'feedback') {
const body = await readJsonBody(req);
const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
if (!result) return sendJson(res, 409, { error: 'session already ended' });
wake.emit(`wake:${key}`);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, pending: result.pending });
}
if (action === 'end') {
endSession(key, 'user');
return sendJson(res, 200, { status: 'ended', endedBy: 'user' });
}
if (action === 'reply') {
const body = await readJsonBody(req);
if (!body.text || typeof body.text !== 'string') {
return sendJson(res, 400, { error: 'text is required' });
}
const entry = store.addAgentReply(key, body.text);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
return sendJson(res, 200, { status: 'sent', at: entry.at });
}
}
return sendJson(res, 404, { error: 'not found' });
}
function handleEvents(req, res, key) {
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
noteConnectionOpened();
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive'
});
res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
if (!sseClients.has(key)) sseClients.set(key, new Set());
sseClients.get(key).add(res);
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
if (ping.unref) ping.unref();
req.on('close', () => {
clearInterval(ping);
const clients = sseClients.get(key);
if (clients) {
clients.delete(res);
if (clients.size === 0) sseClients.delete(key);
}
noteConnectionClosed();
});
}
function serveArtifact(res, key, assetPath) {
const session = store.get(key);
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
if (!assetPath) {
let content;
try {
content = fs.readFileSync(session.file, 'utf8');
} catch {
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
}
const ext = path.extname(session.file).toLowerCase();
if (ext === '.md' || ext === '.markdown') {
const html = renderMarkdownArtifactHtml(renderMarkdown(content), {
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
return sendHtml(res, 200, html, { csp: false });
}
const sdkTag = '<script src="/sdk.js"></script>';
const injected = content.includes('</body>')
? content.replace('</body>', `${sdkTag}\n</body>`)
: `${content}\n${sdkTag}`;
return sendHtml(res, 200, injected, { csp: false });
}
// Sibling assets resolve relative to the artifact's directory and must
// stay confined to it.
const baseDir = path.dirname(session.file);
const resolved = path.resolve(baseDir, assetPath);
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
}
let data;
try {
data = fs.readFileSync(resolved);
} catch {
return sendJson(res, 404, { error: 'asset not found' });
}
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
return res.end(data);
}
const server = http.createServer((req, res) => {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
return sendJson(res, 403, { error: 'forbidden host header' });
}
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
return sendJson(res, 403, { error: 'forbidden origin' });
}
const url = new URL(req.url, `http://${req.headers.host}`);
const { pathname } = url;
Promise.resolve()
.then(() => {
if (req.method === 'GET' && pathname === '/health') {
return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version });
}
if (req.method === 'POST' && pathname === '/shutdown') {
sendJson(res, 200, { status: 'stopping' });
setImmediate(() => {
if (onIdleShutdown) onIdleShutdown();
});
return undefined;
}
if (req.method === 'GET' && pathname === '/') {
return sendHtml(res, 200, renderSessionListHtml(store.list()));
}
if (req.method === 'GET' && pathname === '/canvas.css') {
res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' });
return res.end(canvasCss());
}
if (req.method === 'GET' && pathname === '/client.js') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
return res.end(canvasClientJs());
}
if (req.method === 'GET' && pathname === '/sdk.js') {
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
return res.end(artifactSdkJs());
}
const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/);
if (req.method === 'GET' && canvasMatch) {
const session = store.get(canvasMatch[1]);
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
return sendHtml(res, 200, renderCanvasHtml(session));
}
const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/);
if (req.method === 'GET' && eventsMatch) {
return handleEvents(req, res, eventsMatch[1]);
}
const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
if (req.method === 'GET' && artifactMatch) {
const assetPath = decodeURIComponent(artifactMatch[2]);
return serveArtifact(res, artifactMatch[1], assetPath || null);
}
if (pathname.startsWith('/api/')) {
return handleApi(req, res, url);
}
return sendJson(res, 404, { error: 'not found' });
})
.catch(error => {
if (!res.headersSent) sendJson(res, 400, { error: error.message });
else res.end();
});
});
function close() {
closed = true;
clearTimeout(idleTimer);
for (const key of watchers.keys()) unwatchSession(key);
for (const clients of sseClients.values()) {
for (const client of clients) client.end();
}
sseClients.clear();
wake.emit('server-close');
return new Promise((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()));
// Browser keep-alive sockets would otherwise hold close() open.
if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
});
}
function listen(port = resolvePort()) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
armIdleTimer();
resolve({ port: server.address().port, host });
});
});
}
return { server, listen, close, presenceFor, watchSession };
}
module.exports = {
DEFAULT_HOST,
DEFAULT_PORT,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
};

View file

@ -0,0 +1,269 @@
'use strict';
/**
* Plan Canvas session store.
*
* Sessions are keyed by the canonical artifact file path so agents never
* juggle opaque ids. State is persisted as JSON in the Plan Canvas state
* dir so queued human feedback survives a server restart.
*/
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']);
const VERDICTS = new Set(['approve', 'request-changes']);
function resolveStateDir(env = process.env) {
const override = env.ECC_PLAN_CANVAS_STATE_DIR;
if (override && String(override).trim()) return path.resolve(String(override).trim());
return path.join(os.homedir(), '.claude', 'plan-canvas');
}
// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the
// same session.
function canonicalizeArtifactPath(filePath) {
const absolute = path.resolve(filePath);
try {
return fs.realpathSync(absolute);
} catch {
return absolute;
}
}
function sessionKeyFor(canonicalPath) {
return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
}
function nowIso() {
return new Date().toISOString();
}
function sanitizeText(value, maxLength = 4000) {
if (typeof value !== 'string') return '';
return value.slice(0, maxLength);
}
// Normalize one browser-submitted feedback item into the shape delivered to
// the agent. Returns null for unusable input rather than throwing so a
// malformed item can never wedge the queue.
function normalizeFeedbackItem(raw, counter) {
if (!raw || typeof raw !== 'object') return null;
const kind = FEEDBACK_KINDS.has(raw.kind) ? raw.kind : null;
if (!kind) return null;
const item = {
id: `fb-${counter}`,
kind,
text: sanitizeText(raw.text),
at: nowIso()
};
if (kind === 'verdict') {
if (!VERDICTS.has(raw.verdict)) return null;
item.verdict = raw.verdict;
}
if (kind === 'annotation') {
const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null;
if (!anchor || typeof anchor.selector !== 'string') return null;
item.anchor = {
selector: sanitizeText(anchor.selector, 500),
tag: sanitizeText(anchor.tag, 60),
snippet: sanitizeText(anchor.snippet, 400)
};
if (anchor.textRange && typeof anchor.textRange === 'object') {
item.anchor.textRange = {
text: sanitizeText(anchor.textRange.text, 1000)
};
}
if (!item.text) return null;
}
if (kind === 'chat' && !item.text) return null;
return item;
}
function createSessionStore({ stateDir = resolveStateDir() } = {}) {
const stateFile = path.join(stateDir, 'sessions.json');
let state = { sessions: {}, feedbackCounter: 0 };
function load() {
try {
const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.sessions) {
state = {
sessions: parsed.sessions,
feedbackCounter: Number(parsed.feedbackCounter) || 0
};
}
} catch {
// Missing or corrupt state starts fresh; queued feedback loss on a
// corrupt file beats refusing to start at all.
}
}
function persist() {
fs.mkdirSync(stateDir, { recursive: true });
const tmpFile = `${stateFile}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
fs.renameSync(tmpFile, stateFile);
}
load();
function get(key) {
return state.sessions[key] || null;
}
function findByFile(filePath) {
const canonical = canonicalizeArtifactPath(filePath);
return get(sessionKeyFor(canonical));
}
// Open (or resume) a session. A session the *user* ended from the browser
// is sticky: it refuses a plain reopen so agents do not pop the browser
// back up uninvited. Pass reopen:true only when the human asked.
function open(filePath, { reopen = false } = {}) {
const canonical = canonicalizeArtifactPath(filePath);
const key = sessionKeyFor(canonical);
const existing = state.sessions[key];
if (existing && existing.status === 'ended' && existing.endedBy === 'user' && !reopen) {
return { session: existing, refused: true };
}
const session = existing || {
key,
file: canonical,
chat: [],
pendingFeedback: [],
createdAt: nowIso()
};
session.status = 'open';
delete session.endedBy;
session.updatedAt = nowIso();
state.sessions[key] = session;
persist();
return { session, refused: false };
}
// Queue feedback from the browser. Chat-shaped items are mirrored into the
// session transcript immediately so the conversation panel stays coherent
// across reloads.
function queueFeedback(key, rawItems, { endSession = false } = {}) {
const session = get(key);
if (!session || session.status === 'ended') return null;
const accepted = [];
for (const raw of Array.isArray(rawItems) ? rawItems : []) {
state.feedbackCounter += 1;
const item = normalizeFeedbackItem(raw, state.feedbackCounter);
if (item) accepted.push(item);
}
session.pendingFeedback.push(...accepted);
for (const item of accepted) {
session.chat.push({ role: 'user', kind: item.kind, text: chatLineFor(item), at: item.at });
}
if (endSession) {
session.status = 'ended';
session.endedBy = 'user';
} else if (accepted.length > 0) {
session.status = 'feedback';
}
session.updatedAt = nowIso();
persist();
return { accepted, pending: session.pendingFeedback.length, session };
}
// Deliver-and-drain: feedback is handed to exactly one await call, after
// which the session flips back to open. An ended session keeps reporting
// ended (with attribution) so agents know to stop polling.
function takeFeedback(key) {
const session = get(key);
if (!session) return { status: 'missing' };
if (session.pendingFeedback.length > 0) {
const items = session.pendingFeedback;
session.pendingFeedback = [];
const result = { status: 'feedback', items };
if (session.status === 'ended') {
result.sessionEnded = true;
result.endedBy = session.endedBy;
} else {
session.status = 'open';
}
session.updatedAt = nowIso();
persist();
return result;
}
if (session.status === 'ended') {
return { status: 'ended', endedBy: session.endedBy };
}
return { status: 'waiting' };
}
function addAgentReply(key, text) {
const session = get(key);
if (!session) return null;
const entry = { role: 'agent', kind: 'chat', text: sanitizeText(text), at: nowIso() };
session.chat.push(entry);
session.updatedAt = nowIso();
persist();
return entry;
}
function end(key, endedBy) {
const session = get(key);
if (!session) return null;
session.status = 'ended';
session.endedBy = endedBy === 'user' ? 'user' : 'agent';
session.updatedAt = nowIso();
persist();
return session;
}
function list() {
return Object.values(state.sessions).map(session => ({
key: session.key,
file: session.file,
status: session.status,
endedBy: session.endedBy,
pending: session.pendingFeedback.length,
updatedAt: session.updatedAt
}));
}
function hasOpenSessions() {
return Object.values(state.sessions).some(session => session.status !== 'ended');
}
return {
stateDir,
stateFile,
open,
get,
findByFile,
queueFeedback,
takeFeedback,
addAgentReply,
end,
list,
hasOpenSessions
};
}
// One-line rendering of a feedback item for the conversation transcript.
function chatLineFor(item) {
if (item.kind === 'verdict') {
const label = item.verdict === 'approve' ? 'Approved the plan' : 'Requested changes';
return item.text ? `${label}: ${item.text}` : label;
}
if (item.kind === 'annotation') {
const where = item.anchor.snippet || item.anchor.selector;
return `[${where}] ${item.text}`;
}
return item.text;
}
module.exports = {
canonicalizeArtifactPath,
createSessionStore,
normalizeFeedbackItem,
resolveStateDir,
sessionKeyFor
};

View file

@ -0,0 +1,542 @@
'use strict';
/**
* Plan Canvas browser chrome: the editor shell that frames an artifact,
* plus the rendered-markdown artifact template.
*
* Visual language mirrors the ECC web dashboard (scripts/dashboard-web.js):
* same design tokens, dark-first with a light theme, accentpink brand
* gradient. Everything is served inline no CDNs, no external assets.
*/
const path = require('path');
const { escapeHtml } = require('./markdown');
// Pinned Mermaid ESM build, loaded in the browser only when an artifact
// actually contains a diagram. Override with a local/vendored URL (e.g. an
// air-gapped mirror) via ECC_PLAN_CANVAS_MERMAID_URL. If the fetch fails, the
// diagram source stays visible as a styled code block — nothing breaks.
const DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
function mermaidUrl(env = process.env) {
const override = env.ECC_PLAN_CANVAS_MERMAID_URL;
return override && String(override).trim() ? String(override).trim() : DEFAULT_MERMAID_URL;
}
// Browser module that renders `<pre class="mermaid">` blocks, themed to match
// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
function mermaidLoaderScript(url) {
return `<script type="module">
try {
const mermaid = (await import(${JSON.stringify(url)})).default;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'dark',
fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
themeVariables: {
primaryColor: '#13161e', primaryBorderColor: '#6885e8', primaryTextColor: '#dfe2e9',
lineColor: '#80859a', secondaryColor: '#191d2a', tertiaryColor: '#101218',
background: '#080a0e', mainBkg: '#13161e', clusterBkg: '#0d0f14'
}
});
await mermaid.run({ querySelector: '.mermaid' });
} catch (err) {
document.querySelectorAll('.mermaid').forEach(el => el.classList.add('mermaid-unrendered'));
console.warn('Mermaid render skipped:', err && err.message);
}
</script>`;
}
// Design tokens shared by the chrome and the markdown artifact template.
const TOKENS_CSS = `
:root{
--bg:#080a0e; --bg2:#0d0f14; --bg3:#13161e; --bg4:#191d2a;
--surface:#101218; --surface-hover:#171a24; --border:#1d2130; --border-light:#272c3e;
--text:#dfe2e9; --text2:#80859a; --text3:#4c5168;
--accent:#6885e8; --accent-glow:rgba(104,133,232,0.15); --accent-dim:#3d5ab8;
--green:#4acb8a; --green-glow:rgba(74,203,138,0.15);
--orange:#eca85a; --orange-glow:rgba(236,168,90,0.15);
--pink:#e26a9e; --pink-glow:rgba(226,106,158,0.15);
--red:#e86060; --red-glow:rgba(232,96,96,0.15);
--teal:#4acbbe; --teal-glow:rgba(74,203,190,0.15);
--radius:8px; --radius-sm:5px;
--font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Inter','Segoe UI',Roboto,sans-serif;
--mono:'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
--shadow:0 1px 2px rgba(0,0,0,0.4);
--shadow-lg:0 8px 32px rgba(0,0,0,0.6);
}
[data-theme="light"]{
--bg:#f4f5f7; --bg2:#ffffff; --bg3:#eaecef; --bg4:#dfe2e6;
--surface:#ffffff; --surface-hover:#f4f5f7; --border:#cdd1d9; --border-light:#dde1e8;
--text:#181b23; --text2:#585e6e; --text3:#9197a8;
--accent:#4560d0; --accent-glow:rgba(69,96,208,0.08); --accent-dim:#2f44a0;
--green:#16a34a; --green-glow:rgba(22,163,74,0.08);
--orange:#d97706; --orange-glow:rgba(217,119,6,0.08);
--pink:#c73877; --pink-glow:rgba(199,56,119,0.08);
--red:#dc2626; --red-glow:rgba(220,38,38,0.08);
--teal:#0d9488; --teal-glow:rgba(13,148,136,0.08);
--shadow:0 1px 2px rgba(0,0,0,0.04);
--shadow-lg:0 8px 32px rgba(0,0,0,0.08);
}
`;
function canvasCss() {
return `${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.4;overflow:hidden}
::selection{background:var(--accent);color:#fff}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
button{font-family:var(--font)}
.bar{display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;background:color-mix(in srgb,var(--bg2) 88%,transparent);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}
.brand{display:flex;align-items:center;gap:9px;min-width:0}
.brand .logo{width:26px;height:26px;flex:none;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff}
.brand .name{font-size:13.5px;font-weight:600;white-space:nowrap}
.brand .file{font-size:11.5px;color:var(--text2);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34vw}
.bar .spacer{flex:1}
.presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
.presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
.presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
.presence[data-state="working"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
.toggle .track{width:30px;height:17px;border-radius:99px;background:var(--bg4);border:1px solid var(--border);position:relative;transition:background .15s}
.toggle .knob{position:absolute;top:1px;left:1px;width:13px;height:13px;border-radius:99px;background:var(--text2);transition:transform .15s,background .15s}
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
.layout{display:flex;height:calc(100% - 52px)}
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
.frame iframe{width:100%;height:100%;border:0;background:#fff}
[data-theme] .frame iframe{background:var(--bg2)}
.panel{width:340px;flex:none;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--bg2)}
.panel h2{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:12px 14px 8px}
.verdict{display:flex;gap:8px;padding:0 14px 12px;border-bottom:1px solid var(--border)}
.verdict button{flex:1;height:30px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s}
.verdict .approve{border:1px solid var(--green);background:var(--green-glow);color:var(--green)}
.verdict .approve:hover{background:var(--green);color:#fff}
.verdict .changes{border:1px solid var(--orange);background:var(--orange-glow);color:var(--orange)}
.verdict .changes:hover{background:var(--orange);color:#fff}
.chat{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px}
.msg{max-width:92%;padding:7px 10px;border-radius:10px;font-size:12.5px;white-space:pre-wrap;word-break:break-word}
.msg.user{align-self:flex-end;background:var(--accent-glow);border:1px solid color-mix(in srgb,var(--accent) 35%,transparent);color:var(--text);border-bottom-right-radius:3px}
.msg.agent{align-self:flex-start;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-bottom-left-radius:3px}
.msg .meta{display:block;font-size:9.5px;color:var(--text3);margin-top:3px}
.msg.kind-annotation{border-left:2px solid var(--teal)}
.msg.kind-verdict{border-left:2px solid var(--green)}
.chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
.queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
.pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
.pill.kind-chat{border-left-color:var(--accent)}
.pill.kind-verdict{border-left-color:var(--green)}
.pill .where{color:var(--teal);font-family:var(--mono);font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pill .body{flex:1;min-width:0;color:var(--text2)}
.pill .txt{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.pill button{border:none;background:none;color:var(--text3);cursor:pointer;font-size:13px;line-height:1;padding:1px}
.pill button:hover{color:var(--red)}
.composer{padding:10px 14px 14px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px}
.composer .hint{font-size:10px;color:var(--text3)}
.composer textarea{width:100%;min-height:60px;max-height:160px;resize:vertical;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12.5px;font-family:var(--font);outline:none;transition:all .15s}
.composer textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.composer .row{display:flex;gap:8px;align-items:center}
.composer .send{flex:1;height:32px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .12s}
.composer .send:hover{background:var(--accent-dim)}
.composer .send:disabled{opacity:.5;cursor:default}
.composer .status{font-size:10.5px;color:var(--text3)}
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:blur(6px);z-index:50}
.overlay.show{display:flex}
.overlay .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);padding:26px 32px;text-align:center;max-width:340px}
.overlay .card h3{font-size:14px;margin-bottom:6px}
.overlay .card p{font-size:12px;color:var(--text2);line-height:1.5}
`;
}
// Client logic for the chrome page (runs in the top window).
function canvasClientJs() {
return `'use strict';
(() => {
const boot = JSON.parse(document.getElementById('pc-session').textContent);
const key = boot.key;
const $ = id => document.getElementById(id);
const frame = $('artifact');
const chatLog = $('chatLog');
const queueEl = $('queue');
const input = $('chatInput');
const sendBtn = $('send');
const statusEl = $('sendStatus');
const presence = $('presence');
const QKEY = 'ecc-plan-canvas:queue:' + key;
let queue = [];
let lastScroll = { x: 0, y: 0 };
let ended = boot.status === 'ended';
let sending = false;
try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
// --- theme ---------------------------------------------------------
const themeKey = 'ecc-plan-canvas:theme';
function applyTheme(t) {
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
$('themeBtn').textContent = t === 'light' ? '\\u263E dark' : '\\u2600 light';
}
let theme = localStorage.getItem(themeKey) || 'dark';
applyTheme(theme);
$('themeBtn').addEventListener('click', () => {
theme = theme === 'light' ? 'dark' : 'light';
localStorage.setItem(themeKey, theme);
applyTheme(theme);
});
// --- annotate mode -------------------------------------------------
let annotate = true;
function setAnnotate(on) {
annotate = on;
$('annotate').setAttribute('aria-pressed', String(on));
postToFrame({ type: 'pc:set-mode', annotate: on });
}
$('annotate').addEventListener('click', () => setAnnotate(!annotate));
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
setAnnotate(!annotate);
}
}, true);
// --- iframe bridge --------------------------------------------------
function postToFrame(msg) {
if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
}
window.addEventListener('message', e => {
if (e.source !== frame.contentWindow) return;
const msg = e.data || {};
if (msg.type === 'pc:queue' && msg.item) addToQueue(msg.item);
else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
else if (msg.type === 'pc:ready') {
postToFrame({ type: 'pc:set-mode', annotate });
postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
}
});
// --- queue ----------------------------------------------------------
function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
function addToQueue(item) { queue.push(item); persistQueue(); renderQueue(); }
function renderQueue() {
queueEl.innerHTML = '';
queue.forEach((item, i) => {
const pill = document.createElement('div');
pill.className = 'pill kind-' + item.kind;
const body = document.createElement('span');
body.className = 'body';
if (item.anchor) {
const where = document.createElement('span');
where.className = 'where';
where.textContent = item.anchor.snippet || item.anchor.selector;
body.appendChild(where);
}
const txt = document.createElement('span');
txt.className = 'txt';
txt.textContent = item.kind === 'verdict' ? (item.verdict === 'approve' ? 'Approve plan' : 'Request changes') + (item.text ? ': ' + item.text : '') : item.text;
body.appendChild(txt);
const rm = document.createElement('button');
rm.textContent = '\\u00D7';
rm.title = 'Remove';
rm.addEventListener('click', () => { queue.splice(i, 1); persistQueue(); renderQueue(); });
pill.append(body, rm);
queueEl.appendChild(pill);
});
}
renderQueue();
// --- chat -----------------------------------------------------------
function renderChat(entries) {
chatLog.innerHTML = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
chatLog.appendChild(empty);
return;
}
for (const entry of entries) {
const div = document.createElement('div');
div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
div.textContent = entry.text;
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
div.appendChild(meta);
chatLog.appendChild(div);
}
chatLog.scrollTop = chatLog.scrollHeight;
}
renderChat(boot.chat || []);
// --- send -----------------------------------------------------------
async function send(extraItems) {
if (ended || sending) return;
const items = queue.slice();
if (extraItems) items.push(...extraItems);
const text = input.value.trim();
if (text) items.push({ kind: 'chat', text });
if (!items.length) {
statusEl.textContent = 'Nothing to send yet - annotate the plan or type a message.';
return;
}
sending = true;
sendBtn.disabled = true;
statusEl.textContent = 'Sending\\u2026';
try {
const res = await fetch('/api/session/' + key + '/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
queue = [];
persistQueue();
renderQueue();
input.value = '';
statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
} catch (err) {
statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
} finally {
sending = false;
sendBtn.disabled = ended;
}
}
sendBtn.addEventListener('click', () => send());
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
$('approve').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'approve' }]));
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
// --- session controls ------------------------------------------------
$('reloadBtn').addEventListener('click', reloadArtifact);
$('endBtn').addEventListener('click', async () => {
if (!window.confirm('End this review session?')) return;
try { await fetch('/api/session/' + key + '/end', { method: 'POST' }); } catch { /* server gone */ }
});
function reloadArtifact() {
const base = frame.getAttribute('data-artifact-src');
frame.src = base + '?t=' + Date.now();
}
function markEnded(endedBy) {
ended = true;
sendBtn.disabled = true;
input.disabled = true;
presence.setAttribute('data-state', 'ended');
presence.querySelector('.label').textContent = 'session ended';
$('endedOverlay').classList.add('show');
$('endedWho').textContent = endedBy === 'agent'
? 'Your agent closed this review.'
: 'You ended this review. Head back to your agent session.';
}
if (ended) markEnded(boot.endedBy);
// --- server events ----------------------------------------------------
const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
function connectEvents() {
const es = new EventSource('/events/' + key);
es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
es.addEventListener('presence', e => {
const state = JSON.parse(e.data).state;
if (ended) return;
presence.setAttribute('data-state', state);
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
});
es.addEventListener('reload', reloadArtifact);
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
es.onerror = () => {
if (ended) return;
presence.setAttribute('data-state', 'waiting');
presence.querySelector('.label').textContent = 'canvas server offline';
};
}
connectEvents();
})();`;
}
// The chrome page: header bar, artifact iframe, conversation rail.
function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canvas.css' } = {}) {
const name = path.basename(session.file);
const bootstrap = JSON.stringify({
key: session.key,
file: session.file,
status: session.status,
endedBy: session.endedBy || null,
chat: session.chat
}).replace(/</g, '\\u003c');
const artifactSrc = `/artifact/${session.key}/`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(name)} · Plan Canvas</title>
<link rel="stylesheet" href="${cssPath}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%236885e8'/><stop offset='1' stop-color='%23e26a9e'/></linearGradient></defs><rect width='100' height='100' rx='22' fill='url(%23g)'/><text x='50' y='68' font-size='52' font-weight='700' font-family='sans-serif' fill='white' text-anchor='middle'>E</text></svg>">
</head>
<body>
<script id="pc-session" type="application/json">${bootstrap}</script>
<header class="bar">
<div class="brand">
<div class="logo">E</div>
<span class="name">Plan Canvas</span>
<span class="file" title="${escapeHtml(session.file)}">${escapeHtml(name)}</span>
</div>
<div class="spacer"></div>
<div id="presence" class="presence" data-state="waiting"><span class="dot"></span><span class="label">agent not connected</span></div>
<div id="annotate" class="toggle" role="switch" aria-pressed="true" title="Toggle annotate mode (Cmd/Ctrl+I)">
<span>Annotate</span><span class="track"><span class="knob"></span></span>
</div>
<button id="themeBtn" class="icon-btn" type="button">light</button>
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
</header>
<div class="layout">
<main class="frame">
<iframe id="artifact" title="Artifact under review" src="${artifactSrc}" data-artifact-src="${artifactSrc}" sandbox="allow-scripts allow-forms allow-popups"></iframe>
<div id="endedOverlay" class="overlay"><div class="card"><h3>Session ended</h3><p id="endedWho"></p></div></div>
</main>
<aside class="panel">
<h2>Plan verdict</h2>
<div class="verdict">
<button id="approve" class="approve" type="button">Approve plan</button>
<button id="changes" class="changes" type="button">Request changes</button>
</div>
<h2>Conversation</h2>
<div id="chatLog" class="chat"></div>
<div id="queue" class="queue"></div>
<div class="composer">
<textarea id="chatInput" placeholder="Message your agent&#10;Enter to send &middot; Shift+Enter for a new line"></textarea>
<div class="row">
<button id="send" class="send" type="button">Send to agent</button>
</div>
<div id="sendStatus" class="status"></div>
<div class="hint">Annotations queue up here until you send them together.</div>
</div>
</aside>
</div>
<script src="${clientPath}"></script>
</body>
</html>`;
}
// ECC-styled document template for rendered markdown plan artifacts.
function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) {
const hasMermaid = bodyHtml.includes('class="mermaid"');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.65;font-size:14.5px}
.doc{max-width:860px;margin:0 auto;padding:44px 36px 90px}
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.6em 0 .55em;letter-spacing:-.01em}
h1{font-size:26px;margin-top:.3em;padding-bottom:.45em;border-bottom:1px solid var(--border)}
h1:after{content:'';display:block;width:56px;height:3px;margin-top:14px;border-radius:2px;background:linear-gradient(90deg,var(--accent),var(--pink))}
h2{font-size:19px;padding-bottom:.3em;border-bottom:1px solid var(--border)}
h3{font-size:15.5px}
h4,h5,h6{font-size:13.5px;color:var(--text2);text-transform:uppercase;letter-spacing:.05em}
p,ul,ol,blockquote,table,pre{margin-bottom:.9em}
ul,ol{padding-left:1.5em}
li{margin:.25em 0}
li.task{list-style:none;margin-left:-1.3em}
li.task input{margin-right:.5em;accent-color:var(--accent)}
a{color:var(--accent);text-decoration:none;border-bottom:1px solid var(--accent-glow)}
a:hover{border-bottom-color:var(--accent)}
code{font-family:var(--mono);font-size:.88em;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:.12em .38em}
pre{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;overflow-x:auto}
pre code{background:none;border:none;padding:0;font-size:12.5px;line-height:1.55}
blockquote{border-left:3px solid var(--accent);background:var(--accent-glow);border-radius:0 var(--radius-sm) var(--radius-sm) 0;padding:8px 14px;color:var(--text2)}
table{width:100%;border-collapse:collapse;font-size:13px;display:block;overflow-x:auto}
th,td{text-align:left;padding:7px 12px;border:1px solid var(--border)}
th{background:var(--bg3);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);white-space:nowrap}
tbody tr:hover{background:var(--surface-hover)}
hr{border:none;border-top:1px solid var(--border);margin:1.6em 0}
img{max-width:100%;border-radius:var(--radius-sm)}
pre.mermaid{font-family:var(--mono);font-size:12.5px;line-height:1.55;white-space:pre-wrap}
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
</style>
</head>
<body>
<article class="doc">
${bodyHtml}
</article>
${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''}
<script src="${sdkSrc}"></script>
</body>
</html>`;
}
// Landing page listing sessions (GET /).
function renderSessionListHtml(sessions) {
const rows = sessions.map(s => {
const status = s.status === 'ended' ? `ended by ${escapeHtml(s.endedBy || 'agent')}` : s.status;
const link = s.status === 'ended'
? escapeHtml(path.basename(s.file))
: `<a href="/canvas/${escapeHtml(s.key)}">${escapeHtml(path.basename(s.file))}</a>`;
return `<tr><td>${link}</td><td class="mono">${escapeHtml(s.file)}</td><td><span class="badge ${escapeHtml(s.status)}">${status}</span></td></tr>`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Plan Canvas · sessions</title>
<style>
${TOKENS_CSS}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--font);background:var(--bg);color:var(--text);padding:40px;line-height:1.5}
.logo{width:30px;height:30px;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:7px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:#fff;margin-right:10px;vertical-align:middle}
h1{font-size:18px;display:inline-block;vertical-align:middle}
table{margin-top:24px;border-collapse:collapse;width:100%;max-width:900px;font-size:13px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
th{color:var(--text3);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
a{color:var(--accent);text-decoration:none}
.mono{font-family:var(--mono);font-size:11.5px;color:var(--text2)}
.badge{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--bg3);border:1px solid var(--border);color:var(--text2)}
.badge.open,.badge.feedback{color:var(--green);border-color:var(--green);background:var(--green-glow)}
.empty{margin-top:24px;color:var(--text3);font-size:13px}
</style>
</head>
<body>
<span class="logo">E</span><h1>Plan Canvas sessions</h1>
${sessions.length ? `<table><thead><tr><th>Artifact</th><th>Path</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="empty">No sessions yet. Ask your agent to open a plan with the plan-canvas skill.</p>'}
</body>
</html>`;
}
module.exports = {
canvasCss,
canvasClientJs,
renderCanvasHtml,
renderMarkdownArtifactHtml,
renderSessionListHtml
};

339
scripts/plan-canvas.js Executable file
View file

@ -0,0 +1,339 @@
#!/usr/bin/env node
'use strict';
/**
* Plan Canvas CLI open plan artifacts in a browser review canvas and block
* on human feedback.
*
* node scripts/plan-canvas.js open .claude/plans/feature.plan.md
* node scripts/plan-canvas.js await .claude/plans/feature.plan.md
* node scripts/plan-canvas.js await <file> --reply "Updated section 3."
* node scripts/plan-canvas.js end <file>
* node scripts/plan-canvas.js stop
*
* Agents: `open` returns immediately (the server is a detached process);
* `await` long-polls until the human sends feedback, a verdict, or ends the
* session, then prints a JSON payload to stdout. Progress notes go to stderr
* so stdout stays parseable.
*/
const fs = require('fs');
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const {
canonicalizeArtifactPath,
createSessionStore,
resolveStateDir,
sessionKeyFor
} = require('./lib/plan-canvas/sessions');
const {
DEFAULT_HOST,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
} = require('./lib/plan-canvas/server');
const VERSION = require('../package.json').version;
function usage() {
return [
'Plan Canvas - review plans and HTML artifacts in the browser',
'',
'Usage:',
' node scripts/plan-canvas.js Show server status and sessions',
' node scripts/plan-canvas.js open <file> Open (or resume) a review session',
' node scripts/plan-canvas.js await <file> Block until the human sends feedback',
' node scripts/plan-canvas.js end <file> End a session as the agent',
' node scripts/plan-canvas.js stop Shut down the canvas server',
' node scripts/plan-canvas.js server Run the server in the foreground',
'',
'Options:',
' open: --no-open Do not launch a browser window',
' --reopen Reopen a session the user ended from the browser',
' await: --reply <msg> Show an agent reply in the canvas chat before waiting',
' --timeout-ms <n> Return {status:"waiting"} after n ms (tests/debug only)',
' server: --port <n> --host <h>',
'',
'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS'
].join('\n');
}
function valueAfter(args, name) {
const index = args.indexOf(name);
return index >= 0 && index + 1 < args.length ? args[index + 1] : null;
}
function serverInfoPath(stateDir) {
return path.join(stateDir, 'server.json');
}
function readServerInfo(stateDir) {
try {
return JSON.parse(fs.readFileSync(serverInfoPath(stateDir), 'utf8'));
} catch {
return null;
}
}
function request(port, method, requestPath, body = null) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const req = http.request(
{
host: DEFAULT_HOST,
port,
method,
path: requestPath,
headers: payload
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }
: {}
},
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
try {
resolve({ statusCode: res.statusCode, body: JSON.parse(data.trim() || '{}') });
} catch {
resolve({ statusCode: res.statusCode, body: {} });
}
});
}
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function healthCheck(port) {
try {
const res = await request(port, 'GET', '/health');
return res.body && res.body.app === 'ecc-plan-canvas' ? res.body : null;
} catch {
return null;
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Start (or reuse) the detached canvas server and return its port. A version
// mismatch after an ECC update restarts the server so browser and CLI never
// disagree about the protocol.
async function ensureServer({ stateDir, port }) {
const health = await healthCheck(port);
if (health && health.version === VERSION) return port;
if (health) {
await request(port, 'POST', '/shutdown').catch(() => {});
for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100);
}
fs.mkdirSync(stateDir, { recursive: true });
const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a');
const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
});
child.unref();
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (await healthCheck(port)) return port;
}
throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
const platform = process.platform;
const [cmd, args] =
platform === 'darwin' ? ['open', [url]]
: platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
try {
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
return true;
} catch {
return false;
}
}
function output(payload) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
async function cmdStatus({ stateDir, port }) {
const health = await healthCheck(port);
if (!health) {
return { server: 'not running', hint: 'open an artifact to start one', stateDir };
}
const sessions = await request(port, 'GET', '/api/sessions');
return { server: `http://${DEFAULT_HOST}:${port}`, version: health.version, sessions: sessions.body.sessions };
}
async function cmdOpen(file, args, { stateDir, port }) {
if (!file) throw new Error('open requires a file path');
if (!fs.existsSync(path.resolve(file))) throw new Error(`artifact not found: ${file}`);
await ensureServer({ stateDir, port });
const res = await request(port, 'POST', '/api/sessions', {
file: path.resolve(file),
reopen: args.includes('--reopen')
});
if (res.statusCode === 409) return res.body;
if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`);
const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`;
const launched = args.includes('--no-open') ? false : openBrowser(url);
return {
status: 'open',
url,
browser: launched ? 'opened' : 'not opened',
next_step:
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
};
}
function awaitRequest(port, file, timeoutMs) {
const params = new URLSearchParams({ file });
if (timeoutMs !== null) params.set('timeoutMs', String(timeoutMs));
return new Promise((resolve, reject) => {
const req = http.request(
{ host: DEFAULT_HOST, port, method: 'GET', path: `/api/await?${params}` },
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data.trim()));
} catch {
reject(new Error('await response was not JSON (server restarted?) - re-run await; feedback is never lost'));
}
});
}
);
req.setTimeout(0);
req.on('error', reject);
req.end();
});
}
async function cmdAwait(file, args, { stateDir, port }) {
if (!file) throw new Error('await requires a file path');
if (!(await healthCheck(port))) {
return { status: 'no-server', hint: 'no canvas server is running; use `open` first', stateDir };
}
const reply = valueAfter(args, '--reply');
if (reply) {
const key = sessionKeyFor(canonicalizeArtifactPath(file));
await request(port, 'POST', `/api/session/${key}/reply`, { text: reply });
}
const timeoutRaw = valueAfter(args, '--timeout-ms');
const timeoutMs = timeoutRaw === null ? null : Number.parseInt(timeoutRaw, 10) || 0;
process.stderr.write('[plan-canvas] waiting for human feedback... leave this running (re-run if interrupted; queued feedback is never lost)\n');
const result = await awaitRequest(port, path.resolve(file), timeoutMs);
if (result.status === 'feedback') {
result.next_step = result.sessionEnded
? 'The user sent this feedback and ended the session. Address it and report in chat; do not reopen the canvas uninvited.'
: 'Address the feedback, then run `ecc-plan-canvas await <file> --reply "<what you changed>"` to answer in the canvas and keep listening.';
} else if (result.status === 'ended') {
result.next_step =
result.endedBy === 'user'
? 'The user ended this review. Stop polling and deliver any remaining updates in chat; do not reopen uninvited.'
: 'Session ended. Stop polling.';
}
return result;
}
async function cmdEnd(file, { port }) {
if (!file) throw new Error('end requires a file path');
if (!(await healthCheck(port))) return { status: 'no-server' };
const res = await request(port, 'POST', '/api/end', { file: path.resolve(file) });
return res.body;
}
async function cmdStop({ stateDir, port }) {
if (!(await healthCheck(port))) return { status: 'not running' };
await request(port, 'POST', '/shutdown').catch(() => {});
fs.rmSync(serverInfoPath(stateDir), { force: true });
return { status: 'stopping' };
}
async function cmdServer(args, { stateDir, port }) {
const portArg = valueAfter(args, '--port');
const hostArg = valueAfter(args, '--host');
const listenPort = portArg !== null ? Number.parseInt(portArg, 10) : port;
const store = createSessionStore({ stateDir });
let shuttingDown = false;
const shutdown = async code => {
if (shuttingDown) return;
shuttingDown = true;
fs.rmSync(serverInfoPath(stateDir), { force: true });
await canvas.close().catch(() => {});
process.exit(code);
};
const canvas = createPlanCanvasServer({
store,
host: hostArg || DEFAULT_HOST,
version: VERSION,
idleTimeoutMs: resolveIdleTimeoutMs(),
onIdleShutdown: () => shutdown(0),
log: line => process.stderr.write(`${line}\n`)
});
const bound = await canvas.listen(listenPort);
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
serverInfoPath(stateDir),
JSON.stringify({ pid: process.pid, port: bound.port, version: VERSION, startedAt: new Date().toISOString() }, null, 2)
);
// Sessions restored from disk resume their file watchers.
for (const session of store.list()) {
if (session.status !== 'ended') canvas.watchSession(store.get(session.key));
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
process.stderr.write(`[plan-canvas] serving on http://${bound.host}:${bound.port}\n`);
return new Promise(() => {}); // run until a signal or idle shutdown
}
async function main(argv = process.argv.slice(2)) {
const args = argv.slice();
if (args.includes('--help') || args.includes('-h')) {
process.stdout.write(`${usage()}\n`);
return 0;
}
const command = args[0] && !args[0].startsWith('--') ? args.shift() : null;
const stateDir = resolveStateDir();
// A running server may sit on a non-default port; trust its recorded info.
const recorded = readServerInfo(stateDir);
const context = { stateDir, port: (recorded && recorded.port) || resolvePort() };
try {
if (command === null) output(await cmdStatus(context));
else if (command === 'open') output(await cmdOpen(args[0], args, context));
else if (command === 'await') output(await cmdAwait(args[0], args, context));
else if (command === 'end') output(await cmdEnd(args[0], context));
else if (command === 'stop') output(await cmdStop(context));
else if (command === 'server') await cmdServer(args, context);
else {
process.stderr.write(`Unknown command: ${command}\n\n${usage()}\n`);
return 1;
}
return 0;
} catch (error) {
output({ error: error.message });
return 1;
}
}
if (require.main === module) {
main().then(code => {
process.exitCode = code;
});
}
module.exports = { main, ensureServer, healthCheck };