#!/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 --reply "Updated section 3." * node scripts/plan-canvas.js end * 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 Open (or resume) a review session', ' node scripts/plan-canvas.js await Block until the human sends feedback', ' node scripts/plan-canvas.js pending Show feedback queued for no listener', ' node scripts/plan-canvas.js typing Show a thinking/typing indicator in chat', ' node scripts/plan-canvas.js end 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 Show an agent reply in the canvas chat before waiting', ' --timeout-ms Return {status:"waiting"} after n ms (tests/debug only)', ' typing: --state Defaults to typing', ' server: --port --host ', '', '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 ` 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 --reply ""` 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 ` 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 };