diff --git a/.agents/skills/plan-canvas/SKILL.md b/.agents/skills/plan-canvas/SKILL.md index 72ea5aef6..8b77e1e26 100644 --- a/.agents/skills/plan-canvas/SKILL.md +++ b/.agents/skills/plan-canvas/SKILL.md @@ -46,12 +46,31 @@ Codex — or just run the `ecc-plan-canvas` commands directly. # 1. Open the artifact in the user's browser (returns immediately) ecc-plan-canvas open .claude/plans/feature.plan.md -# 2. Block until the human responds. Leave running; re-run if interrupted — -# queued feedback is never lost. Run in the background if your harness -# time-limits foreground commands. +# 2. Block until the human responds. Leave running; re-run if interrupted: +# queued feedback is never lost. ecc-plan-canvas await .claude/plans/feature.plan.md ``` +### Stay listening, or the human talks to an empty chair + +Feedback only reaches you while an `await` is actually parked on the session. +If your turn ends with nothing listening, the message sits in the queue and, +from the human's side of the glass, sending appears to do nothing at all. + +So **run `await` as a background task** when your harness supports one (in +Claude Code, a Bash call with `run_in_background: true`). It exits the moment +feedback arrives and the harness hands you the JSON, which keeps the loop alive +across turns instead of dying with the foreground call. A foreground `await` +works too, but only until the harness time-limits it. + +Two backstops exist, and neither is an excuse to skip the above: + +- `ecc-plan-canvas pending` lists feedback queued with no listener. Check it + whenever you are unsure whether you missed something. +- The `stop:plan-canvas-pending` hook blocks your turn from ending while canvas + feedback is undelivered, and hands you the messages. If you are reading + feedback from that hook, you stopped listening too early. + `await` prints JSON when the human acts: ```json @@ -73,12 +92,31 @@ ecc-plan-canvas await .claude/plans/feature.plan.md end the session, and start implementing. `request-changes` means revise the artifact (the canvas live-reloads it) and keep the loop going. -**3. Respond in the canvas**, then keep listening — one command does both: +**3. Always respond in the canvas**, then keep listening. One command does both: ```bash -ecc-plan-canvas await --reply "Split Phase 2 as requested — take a look." +ecc-plan-canvas await --reply "Split Phase 2 as requested. Take a look." ``` +Every human message gets a reply in the canvas, even a one-liner like +"On it, rewriting the risk table now." Silence in the chat panel is +indistinguishable from a broken canvas, which is exactly the failure this loop +exists to prevent. Answer there, not only in the terminal. + +While you work, keep the chat honest with the activity indicator: + +```bash +# animated "agent is thinking..." bubble; refresh it during long work +ecc-plan-canvas typing --state thinking +# switch to "agent is typing..." just before a reply lands +ecc-plan-canvas typing --state typing +``` + +`await` sets `thinking` for you the moment it hands you a batch, and `--reply` +clears it. Both states self-expire, so a crashed agent decays to an honest +"queued" instead of leaving the human watching dots forever. Refresh `thinking` +if a revision takes more than a minute. + **4. End** when review concludes: `ecc-plan-canvas end `. ## Diagrams (Mermaid) @@ -143,8 +181,13 @@ ecc-plan-canvas await --reply "Reworked the risk table." ## Anti-Patterns -- Polling with `--timeout-ms` in a loop — it exists for tests. Leave the - plain `await` running instead. +- Polling with `--timeout-ms` in a loop. It exists for tests. Leave the plain + `await` running instead. +- Ending your turn with no `await` listening while the review is still open. + That is the one failure the human experiences as "I sent a message and + nothing happened". +- Reading the feedback but answering only in the terminal. The human is looking + at the canvas. - Reopening after a user-initiated end "just to show" something. - Pasting the whole plan into chat *and* opening a canvas — pick the canvas and keep the terminal summary to one line. diff --git a/hooks/hooks.json b/hooks/hooks.json index 00ab4d0aa..2eb1ef3ea 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -174,6 +174,17 @@ } ], "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 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 ""`); + } + 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 }; diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index a49bd4fa9..9f6de3722 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -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, diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index 2c250c73e..11b44062a 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -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 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 diff --git a/scripts/lib/plan-canvas/ui.js b/scripts/lib/plan-canvas/ui.js index 0432282f6..a9815aa69 100644 --- a/scripts/lib/plan-canvas/ui.js +++ b/scripts/lib/plan-canvas/ui.js @@ -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'; }; diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 5c26fb59a..816a0be7b 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -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 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', @@ -64,6 +66,7 @@ function usage() { ' --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' @@ -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 ` 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); diff --git a/skills/plan-canvas/SKILL.md b/skills/plan-canvas/SKILL.md index 342033fde..fd45baf6f 100644 --- a/skills/plan-canvas/SKILL.md +++ b/skills/plan-canvas/SKILL.md @@ -47,12 +47,31 @@ Codex — or just run the `ecc-plan-canvas` commands directly. # 1. Open the artifact in the user's browser (returns immediately) ecc-plan-canvas open .claude/plans/feature.plan.md -# 2. Block until the human responds. Leave running; re-run if interrupted — -# queued feedback is never lost. Run in the background if your harness -# time-limits foreground commands. +# 2. Block until the human responds. Leave running; re-run if interrupted: +# queued feedback is never lost. ecc-plan-canvas await .claude/plans/feature.plan.md ``` +### Stay listening, or the human talks to an empty chair + +Feedback only reaches you while an `await` is actually parked on the session. +If your turn ends with nothing listening, the message sits in the queue and, +from the human's side of the glass, sending appears to do nothing at all. + +So **run `await` as a background task** when your harness supports one (in +Claude Code, a Bash call with `run_in_background: true`). It exits the moment +feedback arrives and the harness hands you the JSON, which keeps the loop alive +across turns instead of dying with the foreground call. A foreground `await` +works too, but only until the harness time-limits it. + +Two backstops exist, and neither is an excuse to skip the above: + +- `ecc-plan-canvas pending` lists feedback queued with no listener. Check it + whenever you are unsure whether you missed something. +- The `stop:plan-canvas-pending` hook blocks your turn from ending while canvas + feedback is undelivered, and hands you the messages. If you are reading + feedback from that hook, you stopped listening too early. + `await` prints JSON when the human acts: ```json @@ -74,12 +93,31 @@ ecc-plan-canvas await .claude/plans/feature.plan.md end the session, and start implementing. `request-changes` means revise the artifact (the canvas live-reloads it) and keep the loop going. -**3. Respond in the canvas**, then keep listening — one command does both: +**3. Always respond in the canvas**, then keep listening. One command does both: ```bash -ecc-plan-canvas await --reply "Split Phase 2 as requested — take a look." +ecc-plan-canvas await --reply "Split Phase 2 as requested. Take a look." ``` +Every human message gets a reply in the canvas, even a one-liner like +"On it, rewriting the risk table now." Silence in the chat panel is +indistinguishable from a broken canvas, which is exactly the failure this loop +exists to prevent. Answer there, not only in the terminal. + +While you work, keep the chat honest with the activity indicator: + +```bash +# animated "agent is thinking..." bubble; refresh it during long work +ecc-plan-canvas typing --state thinking +# switch to "agent is typing..." just before a reply lands +ecc-plan-canvas typing --state typing +``` + +`await` sets `thinking` for you the moment it hands you a batch, and `--reply` +clears it. Both states self-expire, so a crashed agent decays to an honest +"queued" instead of leaving the human watching dots forever. Refresh `thinking` +if a revision takes more than a minute. + **4. End** when review concludes: `ecc-plan-canvas end `. ## Diagrams (Mermaid) @@ -144,8 +182,13 @@ ecc-plan-canvas await --reply "Reworked the risk table." ## Anti-Patterns -- Polling with `--timeout-ms` in a loop — it exists for tests. Leave the - plain `await` running instead. +- Polling with `--timeout-ms` in a loop. It exists for tests. Leave the plain + `await` running instead. +- Ending your turn with no `await` listening while the review is still open. + That is the one failure the human experiences as "I sent a message and + nothing happened". +- Reading the feedback but answering only in the terminal. The human is looking + at the canvas. - Reopening after a user-initiated end "just to show" something. - Pasting the whole plan into chat *and* opening a canvas — pick the canvas and keep the terminal summary to one line. diff --git a/tests/hooks/plan-canvas-pending-hook.test.js b/tests/hooks/plan-canvas-pending-hook.test.js new file mode 100644 index 000000000..43545bc98 --- /dev/null +++ b/tests/hooks/plan-canvas-pending-hook.test.js @@ -0,0 +1,194 @@ +/** + * Integration tests for scripts/hooks/plan-canvas-pending.js (Stop) + * + * The hook is the delivery guarantee for canvas chat: without it, feedback the + * human sends while no `await` is parked simply never reaches the agent. + * + * Run with: node tests/hooks/plan-canvas-pending-hook.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-pending.js'); + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function freshStateDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pending-')); +} + +function writeState(stateDir, sessions) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions, feedbackCounter: 0 }, null, 2)); +} + +function sessionRecord(key, file, pendingFeedback, overrides = {}) { + const at = '2026-01-01T00:00:00.000Z'; + return { + key, + file, + status: pendingFeedback.length ? 'feedback' : 'open', + chat: [], + pendingFeedback, + createdAt: at, + updatedAt: at, + ...overrides + }; +} + +function readPending(stateDir, key) { + const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'sessions.json'), 'utf8')); + return state.sessions[key].pendingFeedback; +} + +// The hook resolves the state dir at call time, so the env var has to be set +// before each invocation; a fresh require keeps the cases independent. +function loadHook(stateDir) { + delete require.cache[require.resolve(HOOK)]; + process.env.ECC_PLAN_CANVAS_STATE_DIR = stateDir; + return require(HOOK); +} + +async function runTests() { + console.log('\n=== Testing plan-canvas-pending Stop hook ===\n'); + let passed = 0; + let failed = 0; + const originalStateDir = process.env.ECC_PLAN_CANVAS_STATE_DIR; + + if (await test('blocks the stop and hands over undelivered feedback', async () => { + const stateDir = freshStateDir(); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-project-')); + const artifact = path.join(projectDir, 'feature.plan.md'); + writeState(stateDir, { + aaaaaaaaaaaa: sessionRecord('aaaaaaaaaaaa', artifact, [ + { id: 'fb-1', kind: 'chat', text: 'move phase 2 up', at: '2026-01-01T00:00:00.000Z' } + ]) + }); + const hook = loadHook(stateDir); + const result = await hook.run(JSON.stringify({ cwd: projectDir, stop_hook_active: false })); + const decision = JSON.parse(result.stdout); + assert.strictEqual(decision.decision, 'block'); + assert.ok(decision.reason.includes('move phase 2 up'), 'reason carries the message text'); + assert.ok(decision.reason.includes('--reply'), 'reason tells the agent to answer in the canvas'); + // Drained, so the next Stop does not block on the same message. + assert.deepStrictEqual(readPending(stateDir, 'aaaaaaaaaaaa'), []); + })) passed++; else failed++; + + if (await test('a drained queue does not block a second time', async () => { + const stateDir = freshStateDir(); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-project-')); + const artifact = path.join(projectDir, 'feature.plan.md'); + writeState(stateDir, { + aaaaaaaaaaaa: sessionRecord('aaaaaaaaaaaa', artifact, [ + { id: 'fb-1', kind: 'chat', text: 'first', at: '2026-01-01T00:00:00.000Z' } + ]) + }); + const hook = loadHook(stateDir); + const input = JSON.stringify({ cwd: projectDir }); + const first = await hook.run(input); + assert.strictEqual(JSON.parse(first.stdout).decision, 'block'); + const second = await hook.run(input); + assert.strictEqual(second.stdout, input, 'second stop passes stdin through'); + })) passed++; else failed++; + + if (await test('never blocks twice in a row via stop_hook_active', async () => { + const stateDir = freshStateDir(); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-project-')); + writeState(stateDir, { + aaaaaaaaaaaa: sessionRecord('aaaaaaaaaaaa', path.join(projectDir, 'a.plan.md'), [ + { id: 'fb-1', kind: 'chat', text: 'hello', at: '2026-01-01T00:00:00.000Z' } + ]) + }); + const hook = loadHook(stateDir); + const input = JSON.stringify({ cwd: projectDir, stop_hook_active: true }); + const result = await hook.run(input); + assert.strictEqual(result.stdout, input); + assert.strictEqual(readPending(stateDir, 'aaaaaaaaaaaa').length, 1, 'nothing drained'); + })) passed++; else failed++; + + if (await test('ignores sessions outside the project, unless scope=all', async () => { + const stateDir = freshStateDir(); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-project-')); + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-other-')); + const state = { + bbbbbbbbbbbb: sessionRecord('bbbbbbbbbbbb', path.join(otherDir, 'other.plan.md'), [ + { id: 'fb-1', kind: 'chat', text: 'not yours', at: '2026-01-01T00:00:00.000Z' } + ]) + }; + writeState(stateDir, state); + const hook = loadHook(stateDir); + assert.strictEqual(hook.pendingSessions({ sessions: state }, projectDir, {}).length, 0); + assert.strictEqual( + hook.pendingSessions({ sessions: state }, projectDir, { ECC_PLAN_CANVAS_STOP_SCOPE: 'all' }).length, + 1 + ); + })) passed++; else failed++; + + if (await test('ended sessions and empty queues are left alone', async () => { + const stateDir = freshStateDir(); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-project-')); + const state = { + cccccccccccc: sessionRecord( + 'cccccccccccc', + path.join(projectDir, 'ended.plan.md'), + [{ id: 'fb-1', kind: 'chat', text: 'stale', at: '2026-01-01T00:00:00.000Z' }], + { status: 'ended', endedBy: 'user' } + ), + dddddddddddd: sessionRecord('dddddddddddd', path.join(projectDir, 'quiet.plan.md'), []) + }; + writeState(stateDir, state); + const hook = loadHook(stateDir); + assert.strictEqual(hook.pendingSessions({ sessions: state }, projectDir, {}).length, 0); + const input = JSON.stringify({ cwd: projectDir }); + assert.strictEqual((await hook.run(input)).stdout, input); + })) passed++; else failed++; + + if (await test('renders annotations and verdicts readably', async () => { + const hook = loadHook(freshStateDir()); + assert.strictEqual( + hook.describeItem({ kind: 'annotation', text: 'split this', anchor: { snippet: 'Phase 2' } }), + 'on "Phase 2": split this' + ); + assert.strictEqual(hook.describeItem({ kind: 'verdict', verdict: 'approve' }), 'APPROVED the plan'); + assert.strictEqual( + hook.describeItem({ kind: 'verdict', verdict: 'request-changes', text: 'too vague' }), + 'REQUESTED CHANGES: too vague' + ); + assert.strictEqual(hook.describeItem({ kind: 'chat', text: '' }), null); + assert.strictEqual(hook.describeItem(null), null); + })) passed++; else failed++; + + if (await test('malformed stdin and a missing state dir fail open', async () => { + const hook = loadHook(path.join(os.tmpdir(), 'plan-canvas-does-not-exist-xyz')); + assert.strictEqual((await hook.run('not json')).stdout, 'not json'); + assert.strictEqual((await hook.run('{}')).exitCode, 0); + })) passed++; else failed++; + + if (originalStateDir === undefined) delete process.env.ECC_PLAN_CANVAS_STATE_DIR; + else process.env.ECC_PLAN_CANVAS_STATE_DIR = originalStateDir; + + console.log('\n========================================'); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('========================================\n'); + return failed === 0; +} + +if (require.main === module) { + runTests().then(ok => process.exit(ok ? 0 : 1)); +} + +module.exports = { runTests }; diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 7d164c999..5d1e8745f 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -253,11 +253,120 @@ async function main() { assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)'); assert.strictEqual(result.items[1].verdict, 'request-changes'); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'working')); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking')); await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2)); sse.close(); })) passed++; else failed++; + // Regression: feedback sent with nobody parked on `await` used to leave the + // pill claiming "agent working" while the message sat undelivered forever. + if (await test('feedback with no listener reports queued, not working', async () => { + const queuedArtifact = path.join(tmp, 'queued.plan.md'); + fs.writeFileSync(queuedArtifact, '# Plan: Queued\n'); + const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: queuedArtifact } })); + const sse = openSse(port, opened.key); + await sse.ready; + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting')); + + const post = await request(port, 'POST', `/api/session/${opened.key}/feedback`, { + body: { items: [{ kind: 'chat', text: 'anyone there?' }] } + }); + assert.strictEqual(jsonBody(post).presence, 'queued'); + assert.strictEqual(canvas.presenceFor(opened.key), 'queued'); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'queued')); + + // Draining it hands the batch over and flips the indicator to thinking. + const drained = jsonBody(await request(port, 'GET', `/api/await?key=${opened.key}&timeoutMs=0`)); + assert.strictEqual(drained.status, 'feedback'); + assert.strictEqual(canvas.presenceFor(opened.key), 'thinking'); + sse.close(); + })) passed++; else failed++; + + if (await test('typing endpoint drives the indicator and reply clears it', async () => { + const typingArtifact = path.join(tmp, 'typing.plan.md'); + fs.writeFileSync(typingArtifact, '# Plan: Typing\n'); + const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: typingArtifact } })); + const sse = openSse(port, opened.key); + await sse.ready; + + const typing = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); + assert.strictEqual(jsonBody(typing).presence, 'typing'); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'typing')); + + const thinking = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); + assert.strictEqual(jsonBody(thinking).presence, 'thinking'); + + const bad = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'dancing' } }); + assert.strictEqual(bad.statusCode, 400); + + // A landed reply must take the bubble down, not leave it spinning. + await request(port, 'POST', `/api/session/${opened.key}/reply`, { body: { text: 'done' } }); + assert.strictEqual(canvas.presenceFor(opened.key), 'waiting'); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting')); + sse.close(); + })) passed++; else failed++; + + if (await test('thinking and typing states expire instead of sticking', async () => { + const staleArtifact = path.join(tmp, 'stale.plan.md'); + fs.writeFileSync(staleArtifact, '# Plan: Stale\n'); + const staleStore = createSessionStore({ stateDir: path.join(tmp, 'stale-state') }); + const staleCanvas = createPlanCanvasServer({ + store: staleStore, + version: '9.9.9-test', + idleTimeoutMs: 0, + thinkingStaleMs: 40, + typingExpiryMs: 20, + presenceSweepMs: 0 + }); + const bound = await staleCanvas.listen(0); + const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } })); + + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); + await new Promise(resolve => setTimeout(resolve, 60)); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting'); + + // An abandoned agent decays to queued so the human is never told a + // stalled session is still being worked on. + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); + await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, { + body: { items: [{ kind: 'chat', text: 'still there?' }] } + }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); + await new Promise(resolve => setTimeout(resolve, 60)); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); + await staleCanvas.close(); + })) passed++; else failed++; + + // The stuck pill only self-heals if the decay is pushed to an idle browser + // that is not making any requests of its own. + if (await test('presence sweep pushes the decayed state to an idle browser', async () => { + const sweepArtifact = path.join(tmp, 'sweep.plan.md'); + fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n'); + const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') }); + const sweepCanvas = createPlanCanvasServer({ + store: sweepStore, + version: '9.9.9-test', + idleTimeoutMs: 0, + thinkingStaleMs: 50, + presenceSweepMs: 20 + }); + const bound = await sweepCanvas.listen(0); + const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: sweepArtifact } })); + const sse = openSse(bound.port, opened.key); + await sse.ready; + + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); + await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking')); + + const before = sse.received.length; + await waitFor(() => + sse.received.slice(before).some(e => e.event === 'presence' && e.data.state === 'waiting') + ); + sse.close(); + await sweepCanvas.close(); + })) passed++; else failed++; + if (await test('long-poll heartbeat whitespace arrives before the payload', async () => { const chunks = []; const done = new Promise((resolve, reject) => {