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>
This commit is contained in:
haelyra
2026-08-09 18:15:25 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 649def769b
commit ae303fb6c1
10 changed files with 888 additions and 49 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/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 };
+5 -1
View File
@@ -220,7 +220,11 @@ async function main() {
if (hookModule && typeof hookModule.run === 'function') {
try {
const output = hookModule.run(raw, {
// Awaited so a hook may export `async run()`. Without this an async hook
// hands back a pending Promise, which resolveHookResult reads as "no
// opinion" and silently degrades to pass-through. Synchronous hooks are
// unaffected: awaiting a plain value just costs a microtask.
const output = await hookModule.run(raw, {
hookId,
pluginRoot,
scriptPath,
+111 -11
View File
@@ -29,6 +29,14 @@ 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;
// How long the "agent is thinking" indicator survives without the agent
// checking back in, before presence decays to the honest queued/waiting.
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
// An explicit typing signal expires faster: it means "a reply is seconds away".
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
// Presence is push-based, so expiring states need a tick to re-broadcast on.
const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
const CONTENT_TYPES = {
'.css': 'text/css; charset=utf-8',
@@ -109,6 +117,9 @@ function createPlanCanvasServer({
version = '0.0.0',
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
heartbeatMs = 15000,
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS,
onIdleShutdown = null,
log = () => {}
} = {}) {
@@ -119,18 +130,40 @@ function createPlanCanvasServer({
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 workingKeys = new Map(); // key -> ms timestamp the agent took feedback
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
const watchers = new Map(); // key -> fs.FSWatcher
const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing
let idleTimer = null;
let presenceSweep = null;
let closed = false;
// --- presence + SSE ---------------------------------------------------
function presenceFor(key) {
/**
* Presence never claims more than the server actually knows:
*
* ended session is closed
* typing agent signalled it is composing a reply (self-expiring)
* thinking agent took the feedback and is working on it (self-expiring)
* listening an `await` long poll is parked on this session right now
* queued feedback is sitting undelivered with nobody listening
* waiting nothing queued, nobody listening
*
* `thinking` and `typing` expire on their own so a crashed or distracted
* agent decays to an honest `queued`/`waiting` instead of spinning forever.
* The old `working` pill had no expiry and no re-broadcast, so it stuck at
* "agent working" while nothing at all was listening.
*/
function presenceFor(key, now = Date.now()) {
const session = store.get(key);
if (!session || session.status === 'ended') return 'ended';
const typingAt = typingKeys.get(key);
if (typingAt !== undefined && now - typingAt < typingExpiryMs) return 'typing';
const workingAt = workingKeys.get(key);
if (workingAt !== undefined && now - workingAt < thinkingStaleMs) return 'thinking';
if ((awaitCounts.get(key) || 0) > 0) return 'listening';
return workingKeys.has(key) ? 'working' : 'waiting';
return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
}
function broadcast(key, event, payload) {
@@ -141,7 +174,36 @@ function createPlanCanvasServer({
}
function broadcastPresence(key) {
broadcast(key, 'presence', { state: presenceFor(key) });
const state = presenceFor(key);
lastPresence.set(key, state);
broadcast(key, 'presence', { state });
}
// Re-broadcast only where an expiry actually changed the answer, so an
// untouched canvas sees the thinking bubble clear itself.
function sweepPresence() {
for (const key of sseClients.keys()) {
const state = presenceFor(key);
if (lastPresence.get(key) !== state) broadcastPresence(key);
}
}
function startPresenceSweep() {
if (presenceSweep || !presenceSweepMs) return;
presenceSweep = setInterval(sweepPresence, presenceSweepMs);
if (presenceSweep.unref) presenceSweep.unref();
}
// The agent is off working on this feedback batch; start the thinking clock.
function markThinking(key) {
workingKeys.set(key, Date.now());
typingKeys.delete(key);
}
// A reply landed (or the agent picked the session back up): stop pretending.
function clearAgentActivity(key) {
workingKeys.delete(key);
typingKeys.delete(key);
}
function connectionCount() {
@@ -205,6 +267,7 @@ function createPlanCanvasServer({
function endSession(key, endedBy) {
const session = store.end(key, endedBy);
if (!session) return null;
clearAgentActivity(key);
wake.emit(`wake:${key}`);
broadcast(key, 'ended', { endedBy: session.endedBy });
broadcastPresence(key);
@@ -260,7 +323,7 @@ function createPlanCanvasServer({
const first = store.takeFeedback(key);
if (first.status !== 'waiting') {
if (first.status === 'feedback') workingKeys.add(key);
if (first.status === 'feedback') markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, first);
}
@@ -268,7 +331,7 @@ function createPlanCanvasServer({
// Long poll: hold the request open until feedback or session end.
noteConnectionOpened();
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
workingKeys.delete(key);
clearAgentActivity(key);
broadcastPresence(key);
let settled = false;
@@ -279,7 +342,7 @@ function createPlanCanvasServer({
settled = true;
cleanup();
if (payload) {
if (payload.status === 'feedback') workingKeys.add(key);
if (payload.status === 'feedback') markThinking(key);
res.end(JSON.stringify(payload));
}
broadcastPresence(key);
@@ -328,7 +391,7 @@ function createPlanCanvasServer({
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
}
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/);
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
if (sessionMatch && req.method === 'POST') {
const [, key, action] = sessionMatch;
const session = store.get(key);
@@ -341,7 +404,17 @@ function createPlanCanvasServer({
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 });
// A parked `await` takes the batch synchronously on the wake above, so
// presence is already `thinking` by now; with nobody listening it
// reports `queued`. Either way the browser must be told, which the
// original handler never did, leaving a stale pill on screen.
broadcastPresence(key);
return sendJson(res, 200, {
status: 'queued',
accepted: result.accepted.length,
pending: result.pending,
presence: presenceFor(key)
});
}
if (action === 'end') {
@@ -355,9 +428,26 @@ function createPlanCanvasServer({
return sendJson(res, 400, { error: 'text is required' });
}
const entry = store.addAgentReply(key, body.text);
clearAgentActivity(key);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
broadcastPresence(key);
return sendJson(res, 200, { status: 'sent', at: entry.at });
}
// Agents drive the chat indicator explicitly: `thinking` while they work,
// `typing` right before a reply lands, `idle` to take the bubble down.
if (action === 'typing') {
const body = await readJsonBody(req);
const state = typeof body.state === 'string' ? body.state : 'typing';
if (!TYPING_STATES.has(state)) {
return sendJson(res, 400, { error: `state must be one of: ${[...TYPING_STATES].join(', ')}` });
}
if (state === 'idle') clearAgentActivity(key);
else if (state === 'typing') typingKeys.set(key, Date.now());
else markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) });
}
}
return sendJson(res, 404, { error: 'not found' });
@@ -376,6 +466,8 @@ function createPlanCanvasServer({
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);
lastPresence.set(key, presenceFor(key));
startPresenceSweep();
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
if (ping.unref) ping.unref();
req.on('close', () => {
@@ -383,7 +475,10 @@ function createPlanCanvasServer({
const clients = sseClients.get(key);
if (clients) {
clients.delete(res);
if (clients.size === 0) sseClients.delete(key);
if (clients.size === 0) {
sseClients.delete(key);
lastPresence.delete(key);
}
}
noteConnectionClosed();
});
@@ -499,6 +594,9 @@ function createPlanCanvasServer({
function close() {
closed = true;
clearTimeout(idleTimer);
clearInterval(presenceSweep);
presenceSweep = null;
lastPresence.clear();
for (const key of watchers.keys()) unwatchSession(key);
for (const clients of sseClients.values()) {
for (const client of clients) client.end();
@@ -522,12 +620,14 @@ function createPlanCanvasServer({
});
}
return { server, listen, close, presenceFor, watchSession };
return { server, listen, close, presenceFor, sweepPresence, watchSession };
}
module.exports = {
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_THINKING_STALE_MS,
DEFAULT_TYPING_EXPIRY_MS,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
+96 -21
View File
@@ -103,7 +103,8 @@ function canvasCss() {
.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)}
.presence[data-state="thinking"] .dot,.presence[data-state="typing"] .dot{background:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);animation:pulse 1.2s infinite}
.presence[data-state="queued"] .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}
@@ -140,6 +141,23 @@ function canvasCss() {
.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}
/* iMessage-style activity bubble: dots while the agent thinks or types. */
.typing{align-self:flex-start;display:none;align-items:center;gap:8px;background:var(--bg3);border:1px solid var(--border);border-bottom-left-radius:3px;border-radius:10px;padding:9px 12px}
.typing.show{display:flex}
.typing .dots{display:flex;align-items:center;gap:3px}
.typing .dots i{width:6px;height:6px;border-radius:99px;background:var(--text2);animation:typing-bounce 1.4s infinite ease-in-out both}
.typing .dots i:nth-child(1){animation-delay:-.32s}
.typing .dots i:nth-child(2){animation-delay:-.16s}
.typing .label{font-size:11px;color:var(--text3)}
@keyframes typing-bounce{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-4px);opacity:1}}
@media (prefers-reduced-motion:reduce){
.typing .dots i{animation:none;opacity:.7}
.presence .dot{animation:none}
}
/* A queued message nobody is listening for gets an explicit, honest note. */
.stalled{align-self:flex-start;display:none;gap:8px;background:var(--orange-glow);border:1px solid color-mix(in srgb,var(--orange) 35%,transparent);border-radius:10px;padding:8px 11px;font-size:11.5px;color:var(--text2);line-height:1.5}
.stalled.show{display:flex}
.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)}
@@ -267,27 +285,69 @@ function canvasClientJs() {
}
renderQueue();
// --- activity indicators ---------------------------------------------
// Built once and re-appended on every chat render so the animation never
// restarts mid-thought.
const typingEl = document.createElement('div');
typingEl.className = 'typing';
typingEl.setAttribute('role', 'status');
typingEl.setAttribute('aria-live', 'polite');
const dots = document.createElement('span');
dots.className = 'dots';
dots.append(document.createElement('i'), document.createElement('i'), document.createElement('i'));
const typingLabel = document.createElement('span');
typingLabel.className = 'label';
typingEl.append(dots, typingLabel);
const stalledEl = document.createElement('div');
stalledEl.className = 'stalled';
stalledEl.setAttribute('role', 'status');
const TYPING_LABELS = { thinking: 'agent is thinking\\u2026', typing: 'agent is typing\\u2026' };
function renderActivity(state) {
const typingText = TYPING_LABELS[state];
typingEl.classList.toggle('show', Boolean(typingText));
if (typingText) typingLabel.textContent = typingText;
const stalled = state === 'queued';
stalledEl.classList.toggle('show', stalled);
if (stalled) {
stalledEl.textContent =
'Delivered to the queue. Your agent is not listening right now, so it picks this up the moment it checks in.';
}
if (typingText || stalled) scrollToEnd();
}
// --- chat -----------------------------------------------------------
function atBottom() {
return chatLog.scrollHeight - chatLog.scrollTop - chatLog.clientHeight < 40;
}
function scrollToEnd() { chatLog.scrollTop = chatLog.scrollHeight; }
function renderChat(entries) {
const pinned = atBottom();
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;
} else {
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);
}
}
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;
// The indicators live at the tail of the log, so they survive re-render.
chatLog.appendChild(typingEl);
chatLog.appendChild(stalledEl);
if (pinned) scrollToEnd();
}
renderChat(boot.chat || []);
@@ -312,11 +372,17 @@ function canvasClientJs() {
body: JSON.stringify({ items })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const body = await res.json().catch(() => ({}));
queue = [];
persistQueue();
renderQueue();
input.value = '';
statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
// Say what actually happened: a parked agent takes the batch on the
// spot, otherwise it sits in the queue until the agent checks in.
statusEl.textContent = body.presence === 'thinking' || body.presence === 'typing'
? 'Delivered. Your agent has it.'
: 'Queued. Your agent picks this up the moment it checks in.';
if (body.presence) applyPresence(body.presence);
} catch (err) {
statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
} finally {
@@ -345,6 +411,7 @@ function canvasClientJs() {
ended = true;
sendBtn.disabled = true;
input.disabled = true;
renderActivity('ended');
presence.setAttribute('data-state', 'ended');
presence.querySelector('.label').textContent = 'session ended';
$('endedOverlay').classList.add('show');
@@ -355,20 +422,28 @@ function canvasClientJs() {
if (ended) markEnded(boot.endedBy);
// --- server events ----------------------------------------------------
const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
const PRESENCE_LABELS = {
waiting: 'agent not connected',
listening: 'agent listening',
thinking: 'agent is thinking\\u2026',
typing: 'agent is typing\\u2026',
queued: 'queued for your agent'
};
function applyPresence(state) {
if (ended) return;
presence.setAttribute('data-state', state);
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
renderActivity(state);
}
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('presence', e => applyPresence(JSON.parse(e.data).state));
es.addEventListener('reload', reloadArtifact);
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
es.onerror = () => {
if (ended) return;
renderActivity('offline');
presence.setAttribute('data-state', 'waiting');
presence.querySelector('.label').textContent = 'canvas server offline';
};
+35 -1
View File
@@ -45,7 +45,7 @@ const SAFE_REQUEST_PATHS = new Set([
'/api/sessions',
'/api/end'
]);
const SESSION_REPLY_PATH = /^\/api\/session\/[a-f0-9]{12}\/reply$/;
const SESSION_REPLY_PATH = /^\/api\/session\/[a-f0-9]{12}\/(reply|typing)$/;
function usage() {
return [
@@ -55,6 +55,8 @@ function 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',
@@ -64,6 +66,7 @@ function usage() {
' --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'
@@ -293,6 +296,35 @@ async function cmdAwait(file, args, { stateDir, port }) {
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' };
@@ -359,6 +391,8 @@ async function main(argv = process.argv.slice(2)) {
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);