mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-09 19:27:58 +02:00
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:
+35
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user