227 lines
7.8 KiB
JavaScript
227 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Plan Canvas undelivered-feedback guard (Stop)
|
|
*
|
|
* Cross-platform (Windows, macOS, Linux)
|
|
*
|
|
* Browser feedback only reaches an agent while that agent is parked inside
|
|
* `ecc-plan-canvas await`. The moment a turn ends, nothing is listening, so
|
|
* messages the human sends land in sessions.json and stay there: the canvas
|
|
* looks alive, the agent never hears a word.
|
|
*
|
|
* This hook closes that gap. On Stop it drains any undelivered feedback for
|
|
* the current project and blocks the stop, handing the messages to the agent
|
|
* as its next input, so a canvas message is delivered even when no `await`
|
|
* was running.
|
|
*
|
|
* Scope: sessions whose artifact lives under the hook's cwd, so parallel
|
|
* agents in other repos cannot swallow a message meant for this one. Set
|
|
* ECC_PLAN_CANVAS_STOP_SCOPE=all to consider every open session.
|
|
*
|
|
* Never blocks on failure: any error, unreachable server, or undrainable
|
|
* queue exits 0 with stdin passed through.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
// Loopback only, and short: a Stop hook must not stall the turn if the canvas
|
|
// server is wedged. Falling back to the state file keeps delivery working.
|
|
const SERVER_TIMEOUT_MS = 1000;
|
|
const MAX_ITEMS_REPORTED = 20;
|
|
|
|
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 readState() {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(path.join(stateDir(), 'sessions.json'), 'utf8'));
|
|
return parsed && typeof parsed === 'object' && parsed.sessions ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readServerPort() {
|
|
try {
|
|
const info = JSON.parse(fs.readFileSync(path.join(stateDir(), 'server.json'), 'utf8'));
|
|
return Number.isInteger(info.port) ? info.port : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isInside(dir, file) {
|
|
if (!dir) return true;
|
|
const base = path.resolve(dir);
|
|
const target = path.resolve(file);
|
|
return target === base || target.startsWith(base + path.sep);
|
|
}
|
|
|
|
/**
|
|
* Sessions holding feedback the agent has never seen, oldest activity first.
|
|
*/
|
|
function pendingSessions(state, cwd, env = process.env) {
|
|
const scopeAll = String(env.ECC_PLAN_CANVAS_STOP_SCOPE || '').trim().toLowerCase() === 'all';
|
|
return Object.values((state && state.sessions) || {})
|
|
.filter(session => session && session.status !== 'ended')
|
|
.filter(session => Array.isArray(session.pendingFeedback) && session.pendingFeedback.length > 0)
|
|
.filter(session => (scopeAll ? true : isInside(cwd, session.file)))
|
|
.sort((a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')));
|
|
}
|
|
|
|
/**
|
|
* Ask the running server to hand over the batch. The server owns sessions.json
|
|
* while it is up, so this is the only race-free way to drain. timeoutMs=0
|
|
* makes /api/await return immediately instead of long polling.
|
|
*/
|
|
function drainViaServer(port, key) {
|
|
return new Promise(resolve => {
|
|
const req = http.request(
|
|
{
|
|
host: '127.0.0.1',
|
|
port,
|
|
method: 'GET',
|
|
path: `/api/await?key=${encodeURIComponent(key)}&timeoutMs=0`,
|
|
agent: false
|
|
},
|
|
res => {
|
|
let data = '';
|
|
res.on('data', chunk => {
|
|
data += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
try {
|
|
const parsed = JSON.parse(data.trim() || '{}');
|
|
resolve(parsed.status === 'feedback' && Array.isArray(parsed.items) ? parsed : null);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.setTimeout(SERVER_TIMEOUT_MS, () => {
|
|
req.destroy();
|
|
resolve(null);
|
|
});
|
|
req.on('error', () => resolve(null));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drain straight from disk. Only safe when no server is listening, which is
|
|
* exactly when this path runs: with the server down nothing else mutates the
|
|
* file, and leaving the items queued would re-block on every future Stop.
|
|
*/
|
|
function drainViaFile(key) {
|
|
const file = path.join(stateDir(), 'sessions.json');
|
|
try {
|
|
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
const session = state.sessions && state.sessions[key];
|
|
if (!session || !Array.isArray(session.pendingFeedback) || session.pendingFeedback.length === 0) {
|
|
return null;
|
|
}
|
|
const items = session.pendingFeedback;
|
|
const sessionEnded = session.status === 'ended';
|
|
session.pendingFeedback = [];
|
|
if (!sessionEnded) session.status = 'open';
|
|
session.updatedAt = new Date().toISOString();
|
|
const tmp = `${file}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
fs.renameSync(tmp, file);
|
|
return { status: 'feedback', items, sessionEnded };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function describeItem(item) {
|
|
if (!item || typeof item !== 'object') return null;
|
|
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 anchor = item.anchor || {};
|
|
const where = anchor.snippet || anchor.selector || 'the artifact';
|
|
return item.text ? `on "${where}": ${item.text}` : null;
|
|
}
|
|
return item.text || null;
|
|
}
|
|
|
|
function buildReason(delivered) {
|
|
const lines = [
|
|
'Plan Canvas: the human sent feedback in the browser that was never delivered to you.',
|
|
'Handle it now instead of ending the turn.',
|
|
''
|
|
];
|
|
for (const entry of delivered) {
|
|
lines.push(`Artifact: ${entry.file}`);
|
|
for (const text of entry.messages.slice(0, MAX_ITEMS_REPORTED)) lines.push(` - ${text}`);
|
|
const extra = entry.messages.length - MAX_ITEMS_REPORTED;
|
|
if (extra > 0) lines.push(` - (+${extra} more)`);
|
|
if (entry.sessionEnded) {
|
|
lines.push(' The user ended this review after sending. Address the feedback and report back in');
|
|
lines.push(' your normal reply; do not reopen the canvas.');
|
|
} else {
|
|
lines.push(' Reply IN THE CANVAS so the human sees it, and keep listening, with one command:');
|
|
lines.push(` ecc-plan-canvas await ${JSON.stringify(entry.file)} --reply "<what you did>"`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
lines.push('Run that await in the background so the next message reaches you without another Stop.');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
async function collectDeliveries(sessions, port) {
|
|
const delivered = [];
|
|
for (const session of sessions) {
|
|
const result = port ? await drainViaServer(port, session.key) : drainViaFile(session.key);
|
|
// A failed drain is deliberately not reported: blocking on feedback that
|
|
// is still queued would re-fire on every subsequent Stop.
|
|
if (!result) continue;
|
|
const messages = result.items.map(describeItem).filter(Boolean);
|
|
if (messages.length === 0) continue;
|
|
delivered.push({ file: session.file, messages, sessionEnded: Boolean(result.sessionEnded) });
|
|
}
|
|
return delivered;
|
|
}
|
|
|
|
async function run(rawInput) {
|
|
const passThrough = { stdout: rawInput || '', exitCode: 0 };
|
|
let payload = {};
|
|
try {
|
|
payload = JSON.parse(rawInput || '{}');
|
|
} catch {
|
|
return passThrough;
|
|
}
|
|
|
|
// The harness sets this once it has already resumed the agent from a Stop
|
|
// hook. Blocking again from here is how a hook wedges a session.
|
|
if (payload.stop_hook_active) return passThrough;
|
|
|
|
const state = readState();
|
|
if (!state) return passThrough;
|
|
|
|
const sessions = pendingSessions(state, payload.cwd || process.cwd());
|
|
if (sessions.length === 0) return passThrough;
|
|
|
|
const delivered = await collectDeliveries(sessions, readServerPort());
|
|
if (delivered.length === 0) return passThrough;
|
|
|
|
return {
|
|
stdout: JSON.stringify({ decision: 'block', reason: buildReason(delivered) }),
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
module.exports = { run, pendingSessions, describeItem, buildReason, drainViaFile };
|