Files
ECC/scripts/hooks/plan-canvas-pending.js
T
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

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 };