Files
ECC/scripts/plan-canvas.js
ae303fb6c1 fix(plan-canvas): deliver browser chat to the agent every time (#2739)
Feedback sent from the canvas only reached an agent through a live
/api/await long poll. When a turn ended with no await parked,
queueFeedback wrote the message to sessions.json and nothing ever
consumed it, so sending appeared to do nothing at all. The presence pill
made it worse: workingKeys had no expiry and the feedback handler never
broadcast presence, so it froze on "agent working" while nobody was
listening.

Delivery:
- Add the stop:plan-canvas-pending hook. It drains undelivered feedback
  and blocks the Stop, handing the messages to the agent, so a canvas
  message lands even when no await is running. Scoped to sessions under
  cwd so parallel agents cannot swallow each other's feedback; set
  ECC_PLAN_CANVAS_STOP_SCOPE=all to widen. Honors stop_hook_active and
  fails open on every error path.
- run-with-flags.js did not await a hook's run(), so any async hook
  silently degraded to pass-through. Fixed; plan-canvas-pending is the
  only async hook today.

Presence and indicators:
- Presence is now ended/typing/thinking/listening/queued/waiting.
  thinking and typing self-expire (90s/30s) and a 5s sweep pushes the
  decay to an idle browser, so the pill can no longer stick.
- Broadcast presence when feedback is queued, and clear the activity
  state when an agent reply lands.
- Add POST /api/session/:key/typing so agents can drive the indicator.
- Chat shows an animated dots bubble for thinking and typing, plus an
  explicit note when a message is queued with nobody listening.
  Respects prefers-reduced-motion.
- Send status reports what actually happened instead of always claiming
  the agent will pick it up.

CLI and skill:
- Add `ecc-plan-canvas pending` and `typing <file> --state ...`.
- SKILL.md documents background await as the primary pattern and makes
  replying in the canvas mandatory.

Tests: 6 new server cases covering queued presence, the typing endpoint,
state expiry and the sweep, plus a new hook suite covering delivery,
drain-once, stop_hook_active, cwd scoping and fail-open.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:15:25 -04:00

417 lines
15 KiB
JavaScript
Executable File

#!/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;
const SAFE_REQUEST_PATHS = new Set([
'/',
'/health',
'/shutdown',
'/api/await',
'/api/sessions',
'/api/end'
]);
const SESSION_REPLY_PATH = /^\/api\/session\/[a-f0-9]{12}\/(reply|typing)$/;
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 pending Show feedback queued for no listener',
' node scripts/plan-canvas.js typing <file> Show a thinking/typing indicator in chat',
' 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)',
' typing: --state <thinking|typing|idle> Defaults to typing',
' 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 validatePort(port) {
const value = Number(port);
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new Error(`invalid plan-canvas server port: ${port}`);
}
return value;
}
function validateRequestPath(requestPath) {
if (typeof requestPath !== 'string' || !requestPath.startsWith('/')) {
throw new Error('plan-canvas request path must be root-relative');
}
const url = new URL(requestPath, `http://${DEFAULT_HOST}`);
if (url.hostname !== DEFAULT_HOST) {
throw new Error('plan-canvas request path must stay on the loopback server');
}
if (!SAFE_REQUEST_PATHS.has(url.pathname) && !SESSION_REPLY_PATH.test(url.pathname)) {
throw new Error(`unsupported plan-canvas request path: ${url.pathname}`);
}
return `${url.pathname}${url.search}`;
}
function requestOptions(port, method, requestPath, headers) {
return {
host: DEFAULT_HOST,
port: validatePort(port),
method,
path: validateRequestPath(requestPath),
agent: false,
headers
};
}
function request(port, method, requestPath, body = null) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const req = http.request(
requestOptions(
port,
method,
requestPath,
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, key, timeoutMs) {
if (!/^[a-f0-9]{12}$/.test(key)) throw new Error('invalid plan-canvas session key');
const params = new URLSearchParams({ key });
if (timeoutMs !== null) params.set('timeoutMs', String(timeoutMs));
return new Promise((resolve, reject) => {
const req = http.request(
requestOptions(port, 'GET', `/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, sessionKeyFor(canonicalizeArtifactPath(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;
}
// Show the human an activity indicator in the canvas chat. Cheap and
// fire-and-forget: a failed signal must never derail the actual work.
async function cmdTyping(file, args, { port }) {
if (!file) throw new Error('typing requires a file path');
const state = valueAfter(args, '--state') || 'typing';
if (!(await healthCheck(port))) return { status: 'no-server' };
const key = sessionKeyFor(canonicalizeArtifactPath(file));
const res = await request(port, 'POST', `/api/session/${key}/typing`, { state });
if (res.statusCode !== 200) throw new Error(res.body.error || `typing failed (HTTP ${res.statusCode})`);
return { status: 'ok', state, presence: res.body.presence };
}
// Report feedback the human sent that no agent has picked up yet. Reads state
// directly so it answers even when the server has idled out.
function cmdPending({ stateDir }) {
const store = createSessionStore({ stateDir });
const waiting = store
.list()
.filter(session => session.status !== 'ended' && session.pending > 0)
.map(session => ({ file: session.file, pending: session.pending, updatedAt: session.updatedAt }));
return {
status: waiting.length ? 'pending' : 'clear',
sessions: waiting,
next_step: waiting.length
? 'Run `ecc-plan-canvas await <file>` for each file above to receive the messages.'
: 'No canvas feedback is waiting.'
};
}
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 === 'pending') output(cmdPending(context));
else if (command === 'typing') output(await cmdTyping(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 };