const awaitCounts = new Map(); // key -> active long-poll count
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 ---------------------------------------------------
/**
* 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 session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
}
function broadcast(key, event, payload) {
const clients = sseClients.get(key);
if (!clients) return;
const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of clients) client.write(frameText);
}
function broadcastPresence(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() {
let total = 0;
for (const clients of sseClients.values()) total += clients.size;
for (const count of awaitCounts.values()) total += count;
return total;
}
function armIdleTimer() {
if (!idleTimeoutMs || closed) return;
if (connectionCount() > 0) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (connectionCount() === 0 && !closed) {
log('[plan-canvas] idle timeout reached, shutting down');
if (onIdleShutdown) onIdleShutdown();
}
}, idleTimeoutMs);
if (idleTimer.unref) idleTimer.unref();
}
function noteConnectionOpened() {
clearTimeout(idleTimer);
}
function noteConnectionClosed() {
armIdleTimer();
}
// --- artifact watching --------------------------------------------------
function watchSession(session) {
if (watchers.has(session.key)) return;
const dir = path.dirname(session.file);
const base = path.basename(session.file);
let debounce = null;
try {
const watcher = fs.watch(dir, (eventType, filename) => {
if (filename && filename !== base) return;
clearTimeout(debounce);
debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
});
watcher.on('error', () => watchers.delete(session.key));
watchers.set(session.key, watcher);
} catch {
// Watching is best-effort; manual reload still works.
}
}
function unwatchSession(key) {
const watcher = watchers.get(key);
if (watcher) {
watcher.close();
watchers.delete(key);
}
}
// --- session actions ------------------------------------------------------
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);
unwatchSession(key);
return session;
}
// --- request handlers -------------------------------------------------------
async function handleApi(req, res, url) {
const { pathname } = url;
if (req.method === 'POST' && pathname === '/api/sessions') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
if (!fs.existsSync(path.resolve(body.file))) {
return sendJson(res, 404, { error: `artifact not found: ${body.file}` });
}
const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) });
if (refused) {
return sendJson(res, 409, {
status: 'user-ended',
key: session.key,
next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
});
}
watchSession(session);
broadcastPresence(session.key);
return sendJson(res, 200, {
status: 'open',
key: session.key,
file: session.file,
url: `/canvas/${session.key}`
});
}
if (req.method === 'GET' && pathname === '/api/sessions') {
return sendJson(res, 200, { sessions: store.list() });
}
if (req.method === 'GET' && pathname === '/api/await') {
const keyParam = url.searchParams.get('key');
const file = url.searchParams.get('file');
if (keyParam && !/^[a-f0-9]{12}$/.test(keyParam)) return sendJson(res, 400, { error: 'invalid session key' });
if (!keyParam && !file) return sendJson(res, 400, { error: 'key or file query parameter is required' });
const session = keyParam ? store.get(keyParam) : store.findByFile(file);
if (!session) return sendJson(res, 200, { status: 'missing' });
const key = session.key;
const timeoutRaw = url.searchParams.get('timeoutMs');
const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0);
const first = store.takeFeedback(key);
if (first.status !== 'waiting') {
if (first.status === 'feedback') markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, first);
}
// Long poll: hold the request open until feedback or session end.
noteConnectionOpened();
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
clearAgentActivity(key);
broadcastPresence(key);
let settled = false;
let heartbeat = null;
let waitTimer = null;
const finish = payload => {
if (settled) return;
settled = true;
cleanup();
if (payload) {
if (payload.status === 'feedback') markThinking(key);
res.end(JSON.stringify(payload));
}
broadcastPresence(key);
noteConnectionClosed();
};
const onWake = () => {
const result = store.takeFeedback(key);
if (result.status !== 'waiting') finish(result);
};
// Settle held polls on shutdown so server.close() can complete; the
// CLI tells agents to simply re-run await.
const onServerClose = () =>
finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' });
const cleanup = () => {
wake.removeListener(`wake:${key}`, onWake);
wake.removeListener('server-close', onServerClose);
clearInterval(heartbeat);
clearTimeout(waitTimer);
awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1));
};
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
// Leading whitespace keeps the connection visibly alive without
// corrupting the JSON payload written at the end.
res.write(' ');
heartbeat = setInterval(() => {
if (!settled) res.write(' ');
}, heartbeatMs);
if (timeoutMs !== null) {
waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs);
}
wake.on(`wake:${key}`, onWake);
wake.once('server-close', onServerClose);
req.on('close', () => finish(null));
return undefined;
}
if (req.method === 'POST' && pathname === '/api/end') {
const body = await readJsonBody(req);
if (!body.file || typeof body.file !== 'string') {
return sendJson(res, 400, { error: 'file is required' });
}
const session = store.findByFile(body.file);
if (!session) return sendJson(res, 404, { error: 'no session for that file' });
endSession(session.key, 'agent');
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
}
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);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
if (action === 'feedback') {
const body = await readJsonBody(req);
const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
if (!result) return sendJson(res, 409, { error: 'session already ended' });
wake.emit(`wake:${key}`);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
// 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') {
endSession(key, 'user');
return sendJson(res, 200, { status: 'ended', endedBy: 'user' });
}
if (action === 'reply') {
const body = await readJsonBody(req);
if (!body.text || typeof body.text !== 'string') {
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' });
}
function handleEvents(req, res, key) {
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
noteConnectionOpened();
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive'
});
res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
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', () => {
clearInterval(ping);
const clients = sseClients.get(key);
if (clients) {
clients.delete(res);
if (clients.size === 0) {
sseClients.delete(key);
lastPresence.delete(key);
}
}
noteConnectionClosed();
});
}
function serveArtifact(res, key, assetPath) {
const session = store.get(key);
if (!session) return sendHtml(res, 404, 'Unknown session
');
if (!assetPath) {
let content;
try {
content = fs.readFileSync(session.file, 'utf8');
} catch {
return sendHtml(res, 404, `Artifact missing
${escapeHtml(session.file)} no longer exists.
`);
}
const ext = path.extname(session.file).toLowerCase();
if (ext === '.md' || ext === '.markdown') {
const html = renderMarkdownArtifactHtml(renderMarkdown(content), {
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
return sendHtml(res, 200, html, { csp: 'artifact' });
}
const sdkTag = '';
const injected = content.includes('