+ * produced by the marked renderer. This runs AFTER sanitizeHtml so the
+ * generated SVG is never stripped.
+ */
+async function renderMermaidBlocks(root) {
+ if (!_mermaidReady) {
+ // If mermaid load already failed, fall back immediately for new messages
+ if (_mermaidFailed) {
+ const pending = (root || _container)?.querySelectorAll('.chat-mermaid-pending');
+ if (pending?.length) {
+ for (const el of pending) {
+ const src = el.getAttribute('data-mermaid-src') || '';
+ const pre = document.createElement('pre');
+ pre.className = 'chat-codeblock';
+ const langLabel = document.createElement('span');
+ langLabel.className = 'chat-code-lang';
+ langLabel.textContent = 'mermaid';
+ const code = document.createElement('code');
+ code.className = 'language-mermaid';
+ code.textContent = src;
+ pre.appendChild(langLabel);
+ pre.appendChild(code);
+ el.replaceWith(pre);
+ }
+ }
+ }
+ return;
+ }
+ const pending = (root || _container)?.querySelectorAll('.chat-mermaid-pending');
+ if (!pending?.length) return;
+
+ for (const el of pending) {
+ const src = el.getAttribute('data-mermaid-src');
+ if (!src) continue;
+ const id = `chat-mermaid-${_mermaidIdCounter++}`;
+ try {
+ const { svg } = await mermaid.render(id, src);
+ el.className = 'chat-mermaid-rendered';
+ el.removeAttribute('data-mermaid-src');
+ el.innerHTML = svg;
+ } catch {
+ // Fallback: show source as a regular code block
+ el.className = '';
+ const pre = document.createElement('pre');
+ pre.className = 'chat-codeblock';
+ const langLabel = document.createElement('span');
+ langLabel.className = 'chat-code-lang';
+ langLabel.textContent = 'mermaid';
+ const code = document.createElement('code');
+ code.className = 'language-mermaid';
+ code.textContent = src;
+ pre.appendChild(langLabel);
+ pre.appendChild(code);
+ el.replaceWith(pre);
+ }
+ }
+}
+
+/**
+ * Convert all pending mermaid placeholders to code-block fallbacks.
+ * Called when mermaid.js fails to load (CDN blocked, offline, CSP, etc.).
+ */
+function fallbackAllMermaidBlocks() {
+ const pending = _container?.querySelectorAll('.chat-mermaid-pending');
+ if (!pending?.length) return;
+ for (const el of pending) {
+ const src = el.getAttribute('data-mermaid-src') || '';
+ const pre = document.createElement('pre');
+ pre.className = 'chat-codeblock';
+ const langLabel = document.createElement('span');
+ langLabel.className = 'chat-code-lang';
+ langLabel.textContent = 'mermaid';
+ const code = document.createElement('code');
+ code.className = 'language-mermaid';
+ code.textContent = src;
+ pre.appendChild(langLabel);
+ pre.appendChild(code);
+ el.replaceWith(pre);
+ }
+}
+
+// ── Participant colors ──────────────────────────────────────────────
+// Distinct hues keyed by visible pane number so participant colors stay
+// stable across refreshes and independent of join order.
+// Panes 1–9 use hand-picked colors; higher pane numbers get a generated
+// HSL color via golden-angle spacing (~137.5°) for maximum hue separation.
+
+const PANE_COLORS = new Map([
+ [1, '#60a5fa'], // blue
+ [2, '#f472b6'], // pink
+ [3, '#34d399'], // emerald
+ [4, '#fb923c'], // orange
+ [5, '#a78bfa'], // violet
+ [6, '#22d3ee'], // cyan
+ [7, '#fbbf24'], // amber
+ [8, '#e879f9'], // fuchsia
+ [9, '#f87171'], // red
+]);
+const DEFAULT_PARTICIPANT_COLOR = 'var(--df-color-text-muted)';
+
+function getGeneratedParticipantColor(paneNumber) {
+ const hue = (paneNumber * 137.508) % 360;
+ return `hsl(${hue}, 70%, 65%)`;
+}
+
+function getParticipantColor(participant) {
+ const paneNumber = participant?.paneNum ?? null;
+ if (!paneNumber) return DEFAULT_PARTICIPANT_COLOR;
+ return PANE_COLORS.get(paneNumber) ?? getGeneratedParticipantColor(paneNumber);
+}
+
+function findParticipant(name) {
+ return _members.find((member) => member.name === name) ?? { name, paneId: null };
+}
+
+function createMemberStatusIndicator(state) {
+ const indicator = document.createElement('span');
+ indicator.className = `chat-member-status ${state}`;
+ const tooltip = state === 'working' ? 'Working' : 'Idle';
+ indicator.dataset.chatTooltip = tooltip;
+ indicator.setAttribute('aria-label', tooltip);
+ indicator.innerHTML = state === 'working'
+ ? `
`
+ : `
`;
+ return indicator;
+}
+
+function getModeTitle(mode) {
+ return mode === 'auto-accept'
+ ? 'Auto mode: no approval prompts'
+ : mode === 'unrestricted'
+ ? 'Unrestricted mode: all permission checks bypassed'
+ : 'Safe mode: approval prompts enabled';
+}
+
+function getModeIconSvg(mode) {
+ if (mode === 'supervised') {
+ return `
+
+
+
+ `;
+ }
+
+ return `
+
+
+
+
+ `;
+}
+
+function createMemberModeIndicator(mode) {
+ const indicator = document.createElement('span');
+ indicator.className = `chat-member-badge ${mode}`;
+ const tooltip = getModeTitle(mode);
+ indicator.dataset.chatTooltip = tooltip;
+ indicator.setAttribute('aria-label', tooltip);
+ indicator.innerHTML = getModeIconSvg(mode);
+ return indicator;
+}
+
+function createMemberDetachedIndicator() {
+ const indicator = document.createElement('span');
+ indicator.className = 'chat-member-status detached';
+ const tooltip = 'Detached: MCP session closed, waiting for reclaim';
+ indicator.dataset.chatTooltip = tooltip;
+ indicator.setAttribute('aria-label', tooltip);
+ indicator.innerHTML = `
`;
+ return indicator;
+}
+
+// ── Custom tooltip ──────────────────────────────────────────────────
+
+function showTooltip(target) {
+ const el = _container?.querySelector('#chat-tooltip');
+ const text = target?.dataset?.chatTooltip;
+ if (!el || !text) return;
+
+ el.textContent = text;
+ el.classList.remove('hidden');
+
+ const rect = target.getBoundingClientRect();
+ const tipRect = el.getBoundingClientRect();
+ const gap = 6;
+ let top = rect.top - tipRect.height - gap;
+ let left = rect.left + (rect.width / 2) - (tipRect.width / 2);
+
+ if (top < gap) top = rect.bottom + gap;
+ left = Math.max(gap, Math.min(left, window.innerWidth - tipRect.width - gap));
+
+ el.style.top = `${top}px`;
+ el.style.left = `${left}px`;
+ _tooltipTarget = target;
+}
+
+function hideTooltip() {
+ const el = _container?.querySelector('#chat-tooltip');
+ if (!el) return;
+ el.classList.add('hidden');
+ el.textContent = '';
+ _tooltipTarget = null;
+}
+
+function onTooltipOver(e) {
+ const target = e.target?.closest?.('[data-chat-tooltip]');
+ if (!target || target === _tooltipTarget) return;
+ showTooltip(target);
+}
+
+function onTooltipOut(e) {
+ const target = e.target?.closest?.('[data-chat-tooltip]');
+ if (!target) return;
+ if (target.contains(e.relatedTarget)) return;
+ if (_tooltipTarget === target) hideTooltip();
+}
+
+// ── API helpers ─────────────────────────────────────────────────────
+
+async function api(path, opts) {
+ let scopedPath = path;
+ if (_projectId && !/[?&]projectId=/.test(path)) {
+ const [basePath, hash = ''] = path.split('#');
+ const joiner = basePath.includes('?') ? '&' : '?';
+ scopedPath = `${basePath}${joiner}projectId=${encodeURIComponent(_projectId)}${hash ? `#${hash}` : ''}`;
+ }
+
+ return fetch('/api/chat' + scopedPath, {
+ headers: { 'Content-Type': 'application/json' },
+ ...opts,
+ });
+}
+
+async function parseJsonSafely(response) {
+ try {
+ return await response.json();
+ } catch {
+ return null;
+ }
+}
+
+function mergeById(existing, incoming) {
+ const map = new Map();
+ for (const item of existing || []) {
+ if (item?.id) map.set(item.id, item);
+ }
+ for (const item of incoming || []) {
+ if (item?.id) map.set(item.id, item);
+ }
+ return [...map.values()];
+}
+
+function normalizePipeEvent(event) {
+ if (!event || !event.pipeId) return null;
+ const fallbackId = [
+ 'pipe',
+ event.pipeId,
+ event.type || 'event',
+ event.role || event.actionType || event.assignee || event.from || 'ui',
+ event.stage ?? '',
+ ].filter(Boolean).join('-');
+ return {
+ ...event,
+ id: event.id || fallbackId,
+ ts: event.ts || new Date().toISOString(),
+ };
+}
+
+function getTimelineEntries() {
+ return [
+ ..._messages.map(msg => ({ kind: 'message', id: msg.id, ts: msg.ts || '', payload: msg })),
+ ..._pipeEvents.map(event => ({ kind: 'pipe', id: event.id, ts: event.ts || '', payload: event })),
+ ].sort((a, b) => a.ts.localeCompare(b.ts) || a.id.localeCompare(b.id));
+}
+
+// ── HTML ────────────────────────────────────────────────────────────
+
+const BODY_HTML = `
+ ${createHeader({
+ brand: 'Chat',
+ meta: '
',
+ actions: `
+
Rules
+
Clear
+ `,
+ })}
+
+
+
+
+
+
+
New messages below
+
+
+
+ Send
+
+
+
+
+
+
+
+
+
+ Project rules override the built-in default. Reset removes the project override and falls back to the default rules.
+
+
+
+
+
+ Reset To Default
+ Save Rules
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Submit
+
+
+
+
+
+`;
+
+// ── Socket setup ────────────────────────────────────────────────────
+// Reuse the shared dashboard socket (same default namespace used by shell,
+// dashboard, etc.) instead of opening a separate connection.
+
+function connectSocket() {
+ if (_socket) return;
+ _socket = dashboardSocket;
+
+ _socket.on('chat:members', onMembers);
+ _socket.on('chat:join', onJoin);
+ _socket.on('chat:leave', onLeave);
+ _socket.on('chat:message', onMessage);
+ _socket.on('chat:cleared', onCleared);
+ _socket.on('chat:pipe', handlePipeEvent);
+ _socket.on('chat:error', onError);
+}
+
+function disconnectSocket() {
+ if (_socket) {
+ _socket.off('chat:members', onMembers);
+ _socket.off('chat:join', onJoin);
+ _socket.off('chat:leave', onLeave);
+ _socket.off('chat:message', onMessage);
+ _socket.off('chat:cleared', onCleared);
+ _socket.off('chat:pipe', handlePipeEvent);
+ _socket.off('chat:error', onError);
+ // Don't disconnect — shared socket, other pages need it
+ _socket = null;
+ }
+}
+
+// ── Brainstorm action panel ──────────────────────────────────────────────────
+
+function buildBrainstormActions(brainstormId, phase) {
+ const panel = document.createElement('div');
+ panel.className = 'brainstorm-actions';
+ panel.dataset.brainstormId = brainstormId;
+
+ if (phase === 'ideas_review') {
+ panel.appendChild(makeBsBtn('Accept Idea', 'accept', () => brainstormAction(brainstormId, 'accept-idea')));
+ panel.appendChild(makeBsBtn('Retry', 'retry', () => brainstormAction(brainstormId, 'retry-ideas')));
+ panel.appendChild(makeBsBtn('Retry with Note', 'note', () => brainstormActionWithNote(brainstormId, 'retry-ideas')));
+ } else if (phase === 'details_review') {
+ panel.appendChild(makeBsBtn('Finalize', 'accept', () => brainstormAction(brainstormId, 'finalize')));
+ panel.appendChild(makeBsBtn('Adjust', 'note', () => brainstormActionWithNote(brainstormId, 'adjust-details')));
+ panel.appendChild(makeBsBtn('Back to Ideas', 'retry', () => brainstormAction(brainstormId, 'back-to-ideas')));
+ }
+ return panel;
+}
+
+function makeBsBtn(label, variant, onClick) {
+ const btn = document.createElement('button');
+ const variantClass = { accept: 'btn-primary', note: 'btn-secondary', retry: 'btn-ghost' }[variant] || 'btn-secondary';
+ btn.className = `btn btn-sm ${variantClass}`;
+ btn.textContent = label;
+ btn.addEventListener('click', async () => {
+ const result = await onClick();
+ if (result === false) return; // cancelled — keep buttons alive
+ const panel = btn.closest('.brainstorm-actions');
+ if (panel) panel.querySelectorAll('button').forEach(b => { b.disabled = true; });
+ });
+ return btn;
+}
+
+async function brainstormAction(brainstormId, action) {
+ try {
+ await api(`/brainstorms/${brainstormId}/${action}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
+ });
+ } catch { /* socket events will update the UI */ }
+}
+
+function openNoteModal() {
+ return new Promise((resolve) => {
+ const overlay = _container?.querySelector('#chat-note-overlay');
+ const textarea = _container?.querySelector('#chat-note-textarea');
+ const submitBtn = _container?.querySelector('#chat-note-submit');
+ const cancelBtn = _container?.querySelector('#chat-note-cancel');
+ if (!overlay || !textarea) { resolve(null); return; }
+
+ textarea.value = '';
+ overlay.classList.remove('hidden');
+ textarea.focus();
+
+ function cleanup() {
+ overlay.classList.add('hidden');
+ submitBtn?.removeEventListener('click', onSubmit);
+ cancelBtn?.removeEventListener('click', onCancel);
+ overlay.removeEventListener('click', onBackdrop);
+ }
+ function onSubmit() { cleanup(); resolve(textarea.value || null); }
+ function onCancel() { cleanup(); resolve(undefined); } // undefined = cancelled
+ function onBackdrop(e) { if (e.target === overlay) { cleanup(); resolve(undefined); } }
+
+ submitBtn?.addEventListener('click', onSubmit);
+ cancelBtn?.addEventListener('click', onCancel);
+ overlay.addEventListener('click', onBackdrop);
+ });
+}
+
+
+async function brainstormActionWithNote(brainstormId, action) {
+ const note = await openNoteModal();
+ if (note === undefined) return false; // user cancelled — keep buttons alive
+ try {
+ await api(`/brainstorms/${brainstormId}/${action}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ note: note || null }),
+ });
+ } catch { /* socket events will update the UI */ }
+}
+
+function getPipeTag(pipeId) {
+ return `#pipe-${pipeId}`;
+}
+
+function escapeRegExp(text) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function stripLeadingPipeTag(text, pipeId) {
+ if (!text || !pipeId) return text || '';
+ const pattern = new RegExp(`^\\s*${escapeRegExp(getPipeTag(pipeId))}\\s*`, 'i');
+ const stripped = text.replace(pattern, '');
+ return stripped || text;
+}
+
+function createSenderEl(senderText, color) {
+ const sender = document.createElement('div');
+ sender.className = 'chat-msg-sender';
+ sender.style.color = color;
+ sender.textContent = senderText;
+ return sender;
+}
+
+function buildPipeMetaEl(pipeId, label) {
+ const meta = document.createElement('div');
+ meta.className = 'chat-msg-meta chat-pipe-meta';
+
+ const badge = document.createElement('span');
+ badge.className = 'chat-pipe-badge';
+ badge.textContent = getPipeTag(pipeId);
+ meta.appendChild(badge);
+
+ if (label) {
+ const labelEl = document.createElement('span');
+ labelEl.className = 'chat-pipe-label';
+ labelEl.textContent = label;
+ meta.appendChild(labelEl);
+ }
+
+ return meta;
+}
+
+function getPipeOutputLabel(role, stage) {
+ if (role === 'fan-out') return 'Fan-out output';
+ if (stage) return `Stage ${stage} output`;
+ return 'Intermediate output';
+}
+
+function buildPipeHeaderEl(senderText, color, pipeId, label) {
+ const header = document.createElement('div');
+ header.className = 'chat-msg-header';
+ header.appendChild(createSenderEl(senderText, color));
+ header.appendChild(buildPipeMetaEl(pipeId, label));
+ return header;
+}
+
+function buildPipeOutputEl({ id, from, to = null, pipeId, label, content, ts, color, extraClass = '', collapsible = false }) {
+ const el = document.createElement('div');
+ el.className = ['chat-msg', 'from-llm', 'chat-pipe-output', extraClass].filter(Boolean).join(' ');
+ el.dataset.id = id;
+ el.style.borderLeftColor = color;
+
+ const header = buildPipeHeaderEl(formatRecipientHeader(from || 'system', to), color, pipeId, label);
+
+ const body = document.createElement('div');
+ body.className = 'chat-msg-body chat-markdown';
+ body.innerHTML = renderMarkdown(stripLeadingPipeTag(content || '', pipeId));
+
+ const time = document.createElement('div');
+ time.className = 'chat-msg-time';
+ time.textContent = formatTime(ts);
+
+ if (collapsible) {
+ const chevron = document.createElement('span');
+ chevron.className = 'chat-pipe-chevron';
+ chevron.textContent = '\u25B6';
+ header.prepend(chevron);
+ header.classList.add('chat-pipe-toggle');
+ header.style.cursor = 'pointer';
+
+ const detail = document.createElement('div');
+ detail.className = 'chat-pipe-detail hidden';
+ detail.appendChild(body);
+ detail.appendChild(time);
+
+ header.addEventListener('click', () => {
+ const open = detail.classList.toggle('hidden');
+ chevron.textContent = open ? '\u25B6' : '\u25BC';
+ header.classList.toggle('chat-pipe-toggle-open', !open);
+ });
+
+ el.appendChild(header);
+ el.appendChild(detail);
+ } else {
+ el.appendChild(header);
+ el.appendChild(body);
+ el.appendChild(time);
+ }
+
+ return el;
+}
+
+function appendRenderedPipeEventEl(event, doScroll = true) {
+ if (!event || event.type !== 'stage-output' || !event.pipeId) return;
+
+ const listEl = _container?.querySelector('#chat-messages-list');
+ if (!listEl) return;
+
+ const empty = listEl.querySelector('.chat-empty-state');
+ if (empty) empty.remove();
+
+ const color = getParticipantColor(findParticipant(event.from));
+ const el = buildPipeOutputEl({
+ id: event.id,
+ from: event.from,
+ pipeId: event.pipeId,
+ label: getPipeOutputLabel(event.role, event.stage),
+ content: event.content,
+ ts: event.ts,
+ color,
+ extraClass: 'chat-pipe-intermediate',
+ collapsible: true,
+ });
+
+ listEl.appendChild(el);
+ renderMermaidBlocks(el);
+
+ if (doScroll) {
+ if (_autoScroll) {
+ scrollToBottom();
+ } else {
+ showNewIndicator();
+ }
+ }
+}
+
+function appendPipeEventTimelineEl(event, doScroll = true) {
+ appendRenderedPipeEventEl(event, doScroll);
+}
+
+function handlePipeEvent(event) {
+ const normalized = normalizePipeEvent(event);
+ if (!normalized) return;
+ if (_pipeEvents.some(existing => existing.id === normalized.id)) return;
+ _pipeEvents.push(normalized);
+ appendPipeEventTimelineEl(normalized);
+ if (['start', 'complete', 'failed', 'cancel'].includes(normalized.type)) {
+ fetchPipes();
+ }
+}
+
+function onMembers(members) {
+ _members = members;
+ renderMembers();
+}
+
+function onJoin(participant) {
+ const existing = _members.findIndex(m => m.name === participant.name);
+ if (existing >= 0) _members[existing] = participant;
+ else _members.push(participant);
+ renderMembers();
+}
+
+function onLeave({ name }) {
+ _members = _members.filter(m => m.name !== name);
+ renderMembers();
+}
+
+function onMessage(msg) {
+ // Deduplicate by id
+ if (_messages.some(m => m.id === msg.id)) return;
+ _messages.push(msg);
+ appendMessageEl(msg);
+}
+
+function onError(payload) {
+ if (!payload?.error) return;
+ onMessage({
+ id: `local-error-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ ts: new Date().toISOString(),
+ from: 'system',
+ to: null,
+ body: payload.error,
+ type: 'system',
+ });
+}
+
+function onCleared() {
+ _messages = [];
+ _pipeEvents = [];
+ renderAllMessages();
+}
+
+function getPipeShortId(pipeId) {
+ return `#${String(pipeId || '').slice(0, 8)}`;
+}
+
+function getPipeStatusSymbol(status) {
+ if (status === 'running') return 'O';
+ if (status === 'completed') return 'OK';
+ if (status === 'failed') return 'ERR';
+ if (status === 'cancelled') return 'X';
+ return '?';
+}
+
+function formatPipeCountdown(ms) {
+ if (ms == null) return 'leased';
+ if (ms <= 0) return 'overdue';
+ const totalSeconds = Math.ceil(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
+ return `${seconds}s`;
+}
+
+function formatDurationMs(ms) {
+ if (ms == null) return '--';
+ const totalSeconds = Math.round(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
+ return `${seconds}s`;
+}
+
+function renderPipeAlert() {
+ const alertEl = _container?.querySelector('#chat-pipes-alert');
+ if (!alertEl) return;
+ if (_pipeDeadLetters.length === 0) {
+ alertEl.textContent = '';
+ alertEl.classList.add('hidden');
+ return;
+ }
+ alertEl.textContent = `! ${_pipeDeadLetters.length}`;
+ alertEl.classList.remove('hidden');
+}
+
+function getPipeSummary(pipeId) {
+ return _pipeSummaries.find(pipe => pipe.pipeId === pipeId) ?? null;
+}
+
+function getPipeLeases(pipeId) {
+ return _pipeLeases.filter(lease => lease.pipeId === pipeId);
+}
+
+function getPipeDeadLetters(pipeId) {
+ return _pipeDeadLetters.filter(entry => entry.pipeId === pipeId);
+}
+
+function getPipeSlotStatus(slot, leases, deadLetters) {
+ const deadLetter = deadLetters.find(entry => entry.assignee === slot.assignee && entry.role === slot.role && entry.stage === slot.stage);
+ if (deadLetter) return deadLetter.status;
+
+ if (slot.status === 'submitted') {
+ return slot.submittedAt ? `submitted ${formatTime(slot.submittedAt)}` : 'submitted';
+ }
+
+ const lease = leases.find(entry => entry.assignee === slot.assignee && entry.slotRole === slot.role && entry.stage === slot.stage);
+ if (lease) return formatPipeCountdown(lease.remainingMs);
+ return slot.status;
+}
+
+function buildPipeSlotRow(slot, leases, deadLetters) {
+ const row = document.createElement('div');
+ row.className = 'chat-pipe-slot-row';
+
+ const deadLetter = deadLetters.find(entry => entry.assignee === slot.assignee && entry.role === slot.role && entry.stage === slot.stage);
+ if (deadLetter) row.classList.add('chat-pipe-slot-dead');
+
+ const slotLabel = document.createElement('span');
+ slotLabel.className = 'chat-pipe-slot-name';
+ const stageLabel = slot.stage ? ` stage ${slot.stage}` : '';
+ slotLabel.textContent = `${slot.assignee} (${slot.role}${stageLabel})`;
+
+ const slotState = document.createElement('span');
+ slotState.className = 'chat-pipe-slot-state';
+
+ const lease = !deadLetter && slot.status !== 'submitted'
+ ? leases.find(entry => entry.assignee === slot.assignee && entry.slotRole === slot.role && entry.stage === slot.stage)
+ : null;
+
+ if (lease?.deadline) {
+ // Live countdown badge
+ slotState.classList.add('pipe-lease-badge');
+ slotState.dataset.deadline = String(new Date(lease.deadline).getTime());
+ const pipe = getPipeSummary(lease.pipeId);
+ slotState.dataset.timeout = String(pipe?.stageTimeoutMs ?? 0);
+ const remainingMs = new Date(lease.deadline).getTime() - Date.now();
+ slotState.textContent = formatPipeCountdown(remainingMs);
+ // Initial color class
+ const pct = pipe?.stageTimeoutMs ? remainingMs / pipe.stageTimeoutMs : (remainingMs > 0 ? 1 : 0);
+ if (remainingMs <= 0) slotState.classList.add('lease-overdue');
+ else if (pct < 0.25) slotState.classList.add('lease-critical');
+ else if (pct < 0.5) slotState.classList.add('lease-warn');
+ else slotState.classList.add('lease-ok');
+ } else {
+ slotState.textContent = getPipeSlotStatus(slot, leases, deadLetters);
+ if (slot.status === 'submitted') slotState.classList.add('chat-pipe-slot-submitted');
+ }
+
+ row.appendChild(slotLabel);
+ row.appendChild(slotState);
+ return row;
+}
+
+function buildPipeTimingEl(timing) {
+ const section = document.createElement('div');
+ section.className = 'chat-pipe-timing';
+
+ const header = document.createElement('div');
+ header.className = 'chat-pipe-timing-header';
+ header.textContent = `Total: ${formatDurationMs(timing.totalDurationMs)}`;
+ section.appendChild(header);
+
+ if (timing.stages?.length > 0) {
+ for (const stage of timing.stages) {
+ const row = document.createElement('div');
+ row.className = 'chat-pipe-timing-row';
+
+ const label = document.createElement('span');
+ label.className = 'chat-pipe-timing-label';
+ const stageLabel = stage.stage != null ? `stage ${stage.stage}` : stage.role;
+ label.textContent = `${stage.assignee} (${stageLabel})`;
+
+ const dur = document.createElement('span');
+ dur.className = 'chat-pipe-timing-duration';
+ dur.textContent = formatDurationMs(stage.durationMs);
+
+ row.appendChild(label);
+ row.appendChild(dur);
+ section.appendChild(row);
+ }
+ }
+
+ return section;
+}
+
+function buildPipeDetailEl(pipe) {
+ const detail = document.createElement('div');
+ detail.className = 'chat-pipe-row-detail';
+
+ const detailState = _pipeStatusById[pipe.pipeId];
+ const deadLetters = getPipeDeadLetters(pipe.pipeId);
+
+ if (!detailState && _pipeStatusLoading.has(pipe.pipeId)) {
+ const loading = document.createElement('div');
+ loading.className = 'chat-pipe-row-hint';
+ loading.textContent = 'Loading details...';
+ detail.appendChild(loading);
+ } else if (detailState?.prompt) {
+ const prompt = document.createElement('div');
+ prompt.className = 'chat-pipe-row-hint';
+ prompt.textContent = detailState.prompt;
+ detail.appendChild(prompt);
+ }
+
+ const slots = detailState?.slots ?? [];
+ const leases = detailState?.leases ?? getPipeLeases(pipe.pipeId);
+ if (slots.length > 0) {
+ for (const slot of slots) {
+ detail.appendChild(buildPipeSlotRow(slot, leases, deadLetters));
+ }
+ } else {
+ const hint = document.createElement('div');
+ hint.className = 'chat-pipe-row-hint';
+ hint.textContent = pipe.status === 'running' ? 'Expand to inspect pipe state.' : 'No slot detail loaded.';
+ detail.appendChild(hint);
+ }
+
+ if (deadLetters.length > 0) {
+ const issue = document.createElement('div');
+ issue.className = 'chat-pipe-row-issue';
+ issue.textContent = deadLetters.map(entry => `${entry.assignee}: ${entry.reason}`).join(' | ');
+ detail.appendChild(issue);
+ }
+
+ // Timing drilldown for terminal pipes
+ const timing = _pipeTimingById[pipe.pipeId];
+ if (pipe.status !== 'running' && timing) {
+ detail.appendChild(buildPipeTimingEl(timing));
+ } else if (pipe.status !== 'running' && _pipeTimingLoading.has(pipe.pipeId)) {
+ const loadingTiming = document.createElement('div');
+ loadingTiming.className = 'chat-pipe-row-hint';
+ loadingTiming.textContent = 'Loading timing...';
+ detail.appendChild(loadingTiming);
+ }
+
+ if (pipe.status === 'running') {
+ const actionRow = document.createElement('div');
+ actionRow.className = 'chat-pipe-row-actions';
+
+ const cancelBtn = document.createElement('button');
+ cancelBtn.className = 'btn btn-ghost btn-sm';
+ cancelBtn.type = 'button';
+ cancelBtn.textContent = 'Cancel pipe';
+ cancelBtn.addEventListener('click', async (event) => {
+ event.stopPropagation();
+ const confirmed = await confirmModal(_container, {
+ title: 'Cancel pipe?',
+ message: `Cancel pipe
${escapeHtml(getPipeShortId(pipe.pipeId))} ? This will release all leases and stop pending stages.`,
+ confirmLabel: 'Cancel pipe',
+ confirmCls: 'btn-danger',
+ });
+ if (!confirmed) return;
+ cancelBtn.disabled = true;
+ try {
+ await api(`/pipes/${pipe.pipeId}/cancel`, { method: 'POST' });
+ await fetchPipes();
+ } finally {
+ cancelBtn.disabled = false;
+ }
+ });
+
+ actionRow.appendChild(cancelBtn);
+ detail.appendChild(actionRow);
+ }
+
+ return detail;
+}
+
+function buildPipeRowEl(pipe) {
+ const row = document.createElement('div');
+ row.className = 'chat-pipe-row';
+ row.dataset.pipeId = pipe.pipeId;
+
+ const header = document.createElement('button');
+ header.type = 'button';
+ header.className = 'chat-pipe-row-header';
+ header.setAttribute('aria-expanded', String(_expandedPipeId === pipe.pipeId));
+
+ const left = document.createElement('span');
+ left.className = 'chat-pipe-row-main';
+
+ const chevron = document.createElement('span');
+ chevron.className = 'chat-pipe-row-chevron';
+ chevron.textContent = _expandedPipeId === pipe.pipeId ? 'v' : '>';
+
+ const badge = document.createElement('span');
+ badge.className = 'chat-pipe-row-badge';
+ badge.textContent = getPipeShortId(pipe.pipeId);
+
+ const mode = document.createElement('span');
+ mode.className = 'chat-pipe-row-mode';
+ mode.textContent = pipe.mode;
+
+ left.appendChild(chevron);
+ left.appendChild(badge);
+
+ left.appendChild(mode);
+
+ const right = document.createElement('span');
+ right.className = 'chat-pipe-row-meta';
+
+ const status = document.createElement('span');
+ status.className = 'chat-pipe-row-status';
+ status.textContent = getPipeStatusSymbol(pipe.status);
+
+ const progress = document.createElement('span');
+ progress.className = 'chat-pipe-row-progress';
+ progress.textContent = `${pipe.slotSummary?.submitted ?? 0}/${pipe.slotSummary?.total ?? 0}`;
+
+ right.appendChild(status);
+ right.appendChild(progress);
+
+ header.appendChild(left);
+ header.appendChild(right);
+ header.addEventListener('click', async () => {
+ const nextExpanded = _expandedPipeId === pipe.pipeId ? null : pipe.pipeId;
+ _expandedPipeId = nextExpanded;
+ renderPipes();
+ if (nextExpanded) await ensurePipeStatusLoaded(nextExpanded);
+ });
+
+ row.appendChild(header);
+
+ if (_expandedPipeId === pipe.pipeId) {
+ row.appendChild(buildPipeDetailEl(pipe));
+ }
+
+ return row;
+}
+
+function renderPipes() {
+ const listEl = _container?.querySelector('#chat-pipes-list');
+ const titleEl = _container?.querySelector('#chat-pipes-title');
+ const toggleEl = _container?.querySelector('#chat-pipes-toggle');
+ if (!listEl) return;
+
+ renderPipeAlert();
+ if (toggleEl) {
+ toggleEl.textContent = _pipesCollapsed ? 'Show' : 'Hide';
+ toggleEl.setAttribute('aria-expanded', String(!_pipesCollapsed));
+ }
+
+ const {
+ visiblePipes,
+ hiddenTerminalCount,
+ totalCount,
+ totalTerminalCount,
+ canToggleTerminalHistory,
+ } = getVisiblePipeSummaries(_pipeSummaries, {
+ expandedPipeId: _expandedPipeId,
+ showAll: _showAllPipes,
+ terminalLimit: DEFAULT_VISIBLE_TERMINAL,
+ });
+
+ if (titleEl) {
+ titleEl.textContent = hiddenTerminalCount > 0
+ ? `Pipes (${visiblePipes.length} of ${totalCount})`
+ : `Pipes (${totalCount})`;
+ }
+
+ listEl.innerHTML = '';
+ listEl.classList.toggle('hidden', _pipesCollapsed);
+ if (_pipesCollapsed) return;
+
+ if (visiblePipes.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'chat-pipe-row-hint';
+ empty.textContent = 'No active or recent pipes.';
+ listEl.appendChild(empty);
+ return;
+ }
+
+ for (const pipe of visiblePipes) {
+ listEl.appendChild(buildPipeRowEl(pipe));
+ }
+
+ if (canToggleTerminalHistory) {
+ const historyToggle = document.createElement('button');
+ historyToggle.className = 'btn btn-ghost btn-sm chat-pipes-show-all';
+ historyToggle.type = 'button';
+ historyToggle.textContent = _showAllPipes
+ ? 'Show fewer'
+ : `Show all history (${totalTerminalCount})`;
+ historyToggle.addEventListener('click', () => {
+ _showAllPipes = !_showAllPipes;
+ renderPipes();
+ });
+ listEl.appendChild(historyToggle);
+ }
+}
+
+async function fetchPipes() {
+ try {
+ const [allRes, leasesRes, deadLettersRes] = await Promise.all([
+ api('/pipes/all'),
+ api('/pipes/leases'),
+ api('/pipes/dead-letters'),
+ ]);
+
+ if (allRes.ok) {
+ _pipeSummaries = await allRes.json();
+ if (_expandedPipeId && !getPipeSummary(_expandedPipeId)) _expandedPipeId = null;
+ }
+ if (leasesRes.ok) _pipeLeases = await leasesRes.json();
+ if (deadLettersRes.ok) _pipeDeadLetters = await deadLettersRes.json();
+
+ renderPipes();
+ if (_expandedPipeId) ensurePipeStatusLoaded(_expandedPipeId, true);
+ } catch (err) {
+ console.error('[chat] Failed to fetch pipe monitor data:', err);
+ }
+}
+
+async function ensurePipeStatusLoaded(pipeId, force = false) {
+ if (!pipeId || _pipeStatusLoading.has(pipeId)) return;
+ if (!force && _pipeStatusById[pipeId]) return;
+
+ _pipeStatusLoading.add(pipeId);
+ renderPipes();
+ try {
+ const res = await api(`/pipes/${pipeId}/status`);
+ if (!res.ok) return;
+ _pipeStatusById = { ..._pipeStatusById, [pipeId]: await res.json() };
+ // Auto-fetch timing for terminal pipes
+ const pipe = getPipeSummary(pipeId);
+ if (pipe && pipe.status !== 'running') {
+ ensurePipeTimingLoaded(pipeId);
+ }
+ } catch (err) {
+ console.error(`[chat] Failed to load pipe status for ${pipeId}:`, err);
+ } finally {
+ _pipeStatusLoading.delete(pipeId);
+ renderPipes();
+ }
+}
+
+async function ensurePipeTimingLoaded(pipeId, force = false) {
+ if (!pipeId || _pipeTimingLoading.has(pipeId)) return;
+ if (!force && _pipeTimingById[pipeId]) return;
+
+ _pipeTimingLoading.add(pipeId);
+ try {
+ const res = await api(`/pipes/${pipeId}/timing`);
+ if (!res.ok) return;
+ _pipeTimingById = { ..._pipeTimingById, [pipeId]: await res.json() };
+ } catch (err) {
+ console.error(`[chat] Failed to load pipe timing for ${pipeId}:`, err);
+ } finally {
+ _pipeTimingLoading.delete(pipeId);
+ renderPipes();
+ }
+}
+
+function startPipePolling() {
+ stopPipePolling();
+ _pipesPollTimer = setInterval(() => { fetchPipes(); }, PIPE_POLL_INTERVAL_MS);
+}
+
+function stopPipePolling() {
+ if (_pipesPollTimer) {
+ clearInterval(_pipesPollTimer);
+ _pipesPollTimer = null;
+ }
+}
+
+function startLeaseCountdown() {
+ stopLeaseCountdown();
+ _leaseTickTimer = setInterval(() => {
+ const badges = _container?.querySelectorAll('.pipe-lease-badge[data-deadline]');
+ if (!badges || badges.length === 0) return;
+ const now = Date.now();
+ for (const badge of badges) {
+ const deadline = Number(badge.dataset.deadline);
+ const timeout = Number(badge.dataset.timeout) || 0;
+ if (!deadline) continue;
+ const remainingMs = deadline - now;
+ badge.textContent = formatPipeCountdown(remainingMs);
+ // Color-code by percentage of time remaining
+ const pct = timeout > 0 ? remainingMs / timeout : (remainingMs > 0 ? 1 : 0);
+ badge.classList.remove('lease-ok', 'lease-warn', 'lease-critical', 'lease-overdue');
+ if (remainingMs <= 0) {
+ badge.classList.add('lease-overdue');
+ } else if (pct < 0.25) {
+ badge.classList.add('lease-critical');
+ } else if (pct < 0.5) {
+ badge.classList.add('lease-warn');
+ } else {
+ badge.classList.add('lease-ok');
+ }
+ }
+ }, 1000);
+}
+
+function stopLeaseCountdown() {
+ if (_leaseTickTimer) {
+ clearInterval(_leaseTickTimer);
+ _leaseTickTimer = null;
+ }
+}
+
+
+// ── Rendering: Members ──────────────────────────────────────────────
+
+function renderMembers() {
+ const listEl = _container?.querySelector('#chat-members-list');
+ const titleEl = _container?.querySelector('#chat-members-title');
+ const countEl = _container?.querySelector('#chat-member-count');
+ if (!listEl) return;
+
+ // Always show "user" at top
+ const allMembers = [
+ { name: 'user', kind: 'user', paneId: null, isUser: true },
+ ..._members.filter(m => m.name !== 'user'),
+ ];
+
+ const onlineCount = allMembers.filter(m => m.isUser || !m.detached).length;
+ if (titleEl) titleEl.textContent = `Members (${allMembers.length})`;
+ if (countEl) countEl.textContent = `${onlineCount} online`;
+
+ listEl.innerHTML = '';
+ for (const m of allMembers) {
+ const item = document.createElement('div');
+ item.className = 'chat-member-item';
+
+ const dot = document.createElement('span');
+ const isConnected = m.isUser || (m.paneId && !m.detached);
+ dot.className = 'chat-member-dot ' + (isConnected ? 'connected' : m.detached ? 'detached' : 'disconnected');
+
+ const body = document.createElement('div');
+ body.className = 'chat-member-body';
+
+ const name = document.createElement('span');
+ name.className = 'chat-member-name';
+ name.textContent = m.name;
+ name.title = m.name;
+
+ const meta = document.createElement('div');
+ meta.className = 'chat-member-meta';
+
+ // Assign unique color to LLM participants (skip dot color for detached — let CSS handle it)
+ if (!m.isUser) {
+ const color = getParticipantColor(m);
+ if (!m.detached) dot.style.background = color;
+ }
+
+ body.appendChild(name);
+
+ if (m.isUser) {
+ const tag = document.createElement('span');
+ tag.className = 'chat-member-tag';
+ tag.textContent = 'You';
+ meta.appendChild(tag);
+ } else if (m.detached) {
+ meta.appendChild(createMemberDetachedIndicator());
+ } else {
+ const state = m.status || 'idle';
+ meta.appendChild(createMemberStatusIndicator(state));
+ }
+
+ if (!m.isUser) {
+ meta.appendChild(createMemberModeIndicator(m.permissionMode || 'supervised'));
+ }
+
+ body.appendChild(meta);
+
+ item.appendChild(dot);
+ item.appendChild(body);
+ listEl.appendChild(item);
+ }
+}
+
+// ── Rendering: Messages ─────────────────────────────────────────────
+
+function renderAllMessages() {
+ const listEl = _container?.querySelector('#chat-messages-list');
+ if (!listEl) return;
+
+ listEl.innerHTML = '';
+ const entries = getTimelineEntries();
+ for (const entry of entries) {
+ if (entry.kind === 'message') appendMessageEl(entry.payload, false);
+ else appendPipeEventTimelineEl(entry.payload, false);
+ }
+
+ if (listEl.children.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'chat-empty-state';
+ empty.innerHTML = `
+
\u275D
+
No messages yet
+
Send a message or add an LLM from Shell, then join with chat_join
+ `;
+ listEl.appendChild(empty);
+ return;
+ }
+ renderMermaidBlocks();
+ scrollToBottom();
+}
+
+function appendMessageEl(msg, doScroll = true) {
+ const listEl = _container?.querySelector('#chat-messages-list');
+ if (!listEl) return;
+
+ // Hide pipe control delivery messages (handoff/fan-out/synth prompts) from normal chat view
+ const pipeRole = msg.pipe?.role;
+ if (pipeRole && msg.type === 'system' && ['handoff', 'fan-out-request', 'synth-request'].includes(pipeRole)) return;
+
+ // Remove empty state if present
+ const empty = listEl.querySelector('.chat-empty-state');
+ if (empty) empty.remove();
+
+ let el = document.createElement('div');
+ el.className = 'chat-msg';
+ el.dataset.id = msg.id;
+
+ if (msg.type === 'system' || msg.type === 'join' || msg.type === 'leave') {
+ el.classList.add('from-system');
+ el.textContent = msg.body;
+ // Brainstorm review messages get action buttons
+ const bsMatch = msg.body.match(/#brainstorm-([a-z0-9]+)/);
+ if (bsMatch) {
+ const bsId = bsMatch[1];
+ // During historical render (!doScroll) disable buttons if the brainstorm
+ // has advanced past the review phase or is no longer active.
+ // Live messages (doScroll) always get active buttons.
+ const stale = (phase) => !doScroll && (!_brainstorms[bsId] || _brainstorms[bsId].phase !== phase);
+ if (msg.body.includes('Ideas phase complete') || msg.body.includes('Returning to ideas phase')) {
+ const panel = buildBrainstormActions(bsId, 'ideas_review');
+ if (stale('ideas_review')) panel.querySelectorAll('button').forEach(b => { b.disabled = true; });
+ el.appendChild(panel);
+ } else if (msg.body.includes('Detail pass complete')) {
+ const panel = buildBrainstormActions(bsId, 'details_review');
+ if (stale('details_review')) panel.querySelectorAll('button').forEach(b => { b.disabled = true; });
+ el.appendChild(panel);
+ }
+ }
+ } else if (msg.from === 'user') {
+ el.classList.add('from-user');
+ // Show a sender header only when the user explicitly addressed someone
+ // (e.g. `@all check status` → "@user → @all"). Unaddressed user messages
+ // stay header-less so they look like normal user bubbles.
+ if (msg.to) {
+ const userColor = getParticipantColor(findParticipant('user'));
+ el.appendChild(createSenderEl(formatRecipientHeader('user', msg.to), userColor));
+ }
+ const body = document.createElement('div');
+ body.className = 'chat-msg-body chat-markdown';
+ body.innerHTML = renderMarkdown(msg.body);
+ const time = document.createElement('div');
+ time.className = 'chat-msg-time';
+ time.textContent = formatTime(msg.ts);
+ el.appendChild(body);
+ el.appendChild(time);
+ if (msg.unresolvedTargets?.length > 0) {
+ const warn = document.createElement('div');
+ warn.className = 'chat-msg-unresolved-warn';
+ warn.textContent = msg.unresolvedTargets.map(t => `⚠ @${t} not found — message not delivered via PTY`).join('\n');
+ el.appendChild(warn);
+ }
+ } else if (pipeRole && pipeRole !== 'final' && ['stage-output', 'fan-out'].includes(pipeRole)) {
+ // ── Pipe intermediate: rendered separately via pipe-event channel ──
+ return;
+ } else {
+ // ── Regular LLM message or pipe final ──
+ const color = getParticipantColor(findParticipant(msg.from));
+ const isPipeFinal = pipeRole === 'final' && msg.pipe?.pipeId;
+ if (isPipeFinal) {
+ el = buildPipeOutputEl({
+ id: msg.id,
+ from: msg.from,
+ to: msg.to,
+ pipeId: msg.pipe.pipeId,
+ label: 'Final output',
+ content: msg.body,
+ ts: msg.ts,
+ color,
+ });
+ } else {
+ el.classList.add('from-llm');
+ el.style.borderLeftColor = color;
+ const sender = createSenderEl(formatRecipientHeader(msg.from, msg.to), color);
+ const body = document.createElement('div');
+ body.className = 'chat-msg-body chat-markdown';
+ body.innerHTML = renderMarkdown(msg.body);
+ const time = document.createElement('div');
+ time.className = 'chat-msg-time';
+ time.textContent = formatTime(msg.ts);
+ el.appendChild(sender);
+ el.appendChild(body);
+ el.appendChild(time);
+ }
+ }
+
+
+ listEl.appendChild(el);
+ renderMermaidBlocks(el);
+
+ if (doScroll) {
+ if (_autoScroll) {
+ scrollToBottom();
+ } else {
+ showNewIndicator();
+ }
+ }
+}
+
+
+function formatTime(ts) {
+ try {
+ const d = new Date(ts);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ } catch {
+ return '';
+ }
+}
+
+function scrollToBottom() {
+ const listEl = _container?.querySelector('#chat-messages-list');
+ if (listEl) {
+ listEl.scrollTop = listEl.scrollHeight;
+ }
+ hideNewIndicator();
+}
+
+function showNewIndicator() {
+ const el = _container?.querySelector('#chat-new-indicator');
+ if (el) el.classList.remove('hidden');
+}
+
+function hideNewIndicator() {
+ const el = _container?.querySelector('#chat-new-indicator');
+ if (el) el.classList.add('hidden');
+}
+
+function setRulesStatus(message, tone = 'info') {
+ const el = _container?.querySelector('#chat-rules-status');
+ if (!el) return;
+ if (!message) {
+ el.textContent = '';
+ el.className = 'chat-rules-status hidden';
+ return;
+ }
+ el.textContent = message;
+ el.className = `chat-rules-status ${tone}`;
+}
+
+function syncRulesDraftFromInput() {
+ const textarea = _container?.querySelector('#chat-rules-textarea');
+ if (!textarea) return;
+ _rulesDraft = textarea.value;
+}
+
+async function loadRules(force = false) {
+ if (_rulesLoaded && !force) return true;
+ setRulesStatus('Loading rules...');
+ try {
+ const res = await api('/rules');
+ const data = await parseJsonSafely(res);
+ if (!res.ok) throw new Error(data?.error || 'Failed to load rules');
+
+ const rules = typeof data?.rules === 'string' ? data.rules : '';
+ _rulesDraft = rules;
+ _rulesLoaded = true;
+ const textarea = _container?.querySelector('#chat-rules-textarea');
+ if (textarea) textarea.value = rules;
+ setRulesStatus(data?.isDefault ? 'Loaded default rules.' : 'Loaded project override rules.');
+ return true;
+ } catch (err) {
+ setRulesStatus(err instanceof Error ? err.message : 'Failed to load rules.', 'error');
+ return false;
+ }
+}
+
+async function openRulesEditor() {
+ const overlay = _container?.querySelector('#chat-rules-overlay');
+ if (!overlay) return;
+ overlay.classList.remove('hidden');
+ const ok = await loadRules();
+ if (ok) _container?.querySelector('#chat-rules-textarea')?.focus();
+}
+
+function closeRulesEditor() {
+ _container?.querySelector('#chat-rules-overlay')?.classList.add('hidden');
+}
+
+async function saveRules() {
+ syncRulesDraftFromInput();
+ setRulesStatus('Saving rules...');
+ try {
+ const res = await api('/rules', {
+ method: 'PUT',
+ body: JSON.stringify({ rules: _rulesDraft }),
+ });
+ const data = await parseJsonSafely(res);
+ if (!res.ok) throw new Error(data?.error || 'Failed to save rules');
+ _rulesLoaded = true;
+ setRulesStatus('Project rules saved.', 'success');
+ } catch (err) {
+ setRulesStatus(err instanceof Error ? err.message : 'Failed to save rules.', 'error');
+ }
+}
+
+async function resetRules() {
+ setRulesStatus('Resetting rules...');
+ try {
+ const res = await api('/rules', { method: 'DELETE' });
+ const data = await parseJsonSafely(res);
+ if (!res.ok) throw new Error(data?.error || 'Failed to reset rules');
+ _rulesLoaded = false;
+ await loadRules(true);
+ setRulesStatus('Project override removed. Using default rules.', 'success');
+ } catch (err) {
+ setRulesStatus(err instanceof Error ? err.message : 'Failed to reset rules.', 'error');
+ }
+}
+
+// ── Input handling ──────────────────────────────────────────────────
+
+function autoResizeInput(el) {
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 120) + 'px';
+}
+
+function sendMessage() {
+ const input = _container?.querySelector('#chat-input');
+ if (!input) return;
+ if (!input.value.trim()) return;
+ const text = input.value;
+
+ input.value = '';
+ autoResizeInput(input);
+ sessionStorage.removeItem(draftKey(_projectId));
+ closeMentionPopup();
+ input.focus();
+
+ // Let the server resolve all @mentions from the message body
+ _socket.emit('chat:send', { message: text });
+}
+
+// ── @mention autocomplete ───────────────────────────────────────────
+
+function onInputChange(e) {
+ const input = e.target;
+ autoResizeInput(input);
+ const val = input.value;
+ const cursorPos = input.selectionStart;
+ const before = val.substring(0, cursorPos);
+
+ // ── Slash command autocomplete ──
+ // If input starts with '/' and no space yet, suggest pipe commands
+ const slashMatch = before.match(/^\/(\S*)$/);
+ if (slashMatch) {
+ const query = slashMatch[1].toLowerCase();
+ const matches = PIPE_COMMANDS.filter(c => c.name.substring(1).startsWith(query));
+ if (matches.length > 0) {
+ showCommandPopup(matches);
+ return;
+ }
+ }
+
+ // ── Pipe assignee autocomplete ──
+ // If inside a pipe command (before ':'), autocomplete @mentions for connected LLM members only
+ const pipeAssigneeMatch = before.match(/^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+[^:]*@(\w*)$/);
+ if (pipeAssigneeMatch) {
+ const query = pipeAssigneeMatch[2].toLowerCase();
+ const matches = getPipeAssigneeMatches(_members, query);
+ if (matches.length > 0) {
+ const atIdx = before.lastIndexOf('@');
+ showMentionPopup(matches, atIdx);
+ return;
+ }
+ }
+
+ // ── Regular @mention autocomplete ──
+ const atMatch = before.match(/@(\w*)$/);
+ if (atMatch) {
+ const query = atMatch[1].toLowerCase();
+ const matches = getMentionMatches(_members, query);
+
+ if (matches.length > 0) {
+ showMentionPopup(matches, atMatch.index);
+ return;
+ }
+ }
+
+ closeMentionPopup();
+}
+
+function showCommandPopup(commands) {
+ const popup = _container?.querySelector('#chat-mention-popup');
+ if (!popup) return;
+
+ _mentionIdx = 0;
+ _popupMode = 'command';
+ popup.innerHTML = '';
+ popup.classList.remove('hidden');
+
+ for (let i = 0; i < commands.length; i++) {
+ const item = document.createElement('div');
+ item.className = 'chat-mention-item' + (i === 0 ? ' selected' : '');
+ item.innerHTML = `
${escapeHtml(commands[i].name)} ${escapeHtml(commands[i].hint)} `;
+ item.dataset.command = commands[i].name;
+ item.addEventListener('click', () => insertCommand(commands[i].name));
+ popup.appendChild(item);
+ }
+}
+
+function insertCommand(command) {
+ const input = _container?.querySelector('#chat-input');
+ if (!input) return;
+ const afterCursor = input.value.substring(input.selectionStart);
+ input.value = command + ' ' + afterCursor;
+ const newPos = command.length + 1;
+ input.setSelectionRange(newPos, newPos);
+ closeMentionPopup();
+ input.focus();
+}
+
+function showMentionPopup(names, atIndex) {
+ const popup = _container?.querySelector('#chat-mention-popup');
+ if (!popup) return;
+
+ _mentionIdx = 0;
+ _popupMode = 'mention';
+ popup.innerHTML = '';
+ popup.classList.remove('hidden');
+
+ for (let i = 0; i < names.length; i++) {
+ const item = document.createElement('div');
+ item.className = 'chat-mention-item' + (i === 0 ? ' selected' : '');
+ if (names[i] === 'all') {
+ item.innerHTML = `
@all Broadcast to all participants `;
+ } else {
+ item.textContent = '@' + names[i];
+ }
+ item.dataset.name = names[i];
+ item.addEventListener('click', () => insertMention(names[i], atIndex));
+ popup.appendChild(item);
+ }
+}
+
+function closeMentionPopup() {
+ const popup = _container?.querySelector('#chat-mention-popup');
+ if (popup) {
+ popup.classList.add('hidden');
+ popup.innerHTML = '';
+ }
+ _mentionIdx = -1;
+ _popupMode = 'none';
+}
+
+function insertMention(name, atIndex) {
+ const input = _container?.querySelector('#chat-input');
+ if (!input) return;
+ const val = input.value;
+ const before = val.substring(0, atIndex);
+ const afterCursor = val.substring(input.selectionStart);
+ input.value = before + '@' + name + ' ' + afterCursor;
+ const newPos = before.length + name.length + 2;
+ input.setSelectionRange(newPos, newPos);
+ closeMentionPopup();
+ input.focus();
+}
+
+function onInputKeyDown(e) {
+ const popup = _container?.querySelector('#chat-mention-popup');
+ const isPopupOpen = popup && !popup.classList.contains('hidden');
+
+ if (isPopupOpen) {
+ const items = popup.querySelectorAll('.chat-mention-item');
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ items[_mentionIdx]?.classList.remove('selected');
+ _mentionIdx = (_mentionIdx + 1) % items.length;
+ items[_mentionIdx]?.classList.add('selected');
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ items[_mentionIdx]?.classList.remove('selected');
+ _mentionIdx = (_mentionIdx - 1 + items.length) % items.length;
+ items[_mentionIdx]?.classList.add('selected');
+ return;
+ }
+ if (e.key === 'Tab' || e.key === 'Enter') {
+ e.preventDefault();
+ const selected = items[_mentionIdx];
+ if (selected) {
+ // Handle command popup vs mention popup
+ if (_popupMode === 'command' && selected.dataset.command) {
+ insertCommand(selected.dataset.command);
+ } else {
+ const input = _container?.querySelector('#chat-input');
+ const before = input.value.substring(0, input.selectionStart);
+ const atMatch = before.match(/@(\w*)$/);
+ if (atMatch) {
+ insertMention(selected.dataset.name, atMatch.index);
+ }
+ }
+ }
+ return;
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ closeMentionPopup();
+ return;
+ }
+ }
+
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+}
+
+// ── Data loading ────────────────────────────────────────────────────
+
+async function loadInitialData() {
+ try {
+ const [messagesRes, pipeEventsRes, membersRes, brainstormsRes] = await Promise.all([
+ api('/messages?limit=50'),
+ api('/pipe-events?limit=200'),
+ api('/members'),
+ api('/brainstorms'),
+ ]);
+ if (brainstormsRes.ok) {
+ _brainstorms = {};
+ for (const bs of await brainstormsRes.json()) _brainstorms[bs.id] = bs;
+ }
+ if (messagesRes.ok) {
+ _messages = mergeById(_messages, await messagesRes.json());
+ }
+ if (pipeEventsRes.ok) {
+ _pipeEvents = mergeById(_pipeEvents, await pipeEventsRes.json());
+ }
+ if (membersRes.ok) {
+ _members = (await membersRes.json()).map(m => m.kind === 'llm' ? { ...m, status: 'idle' } : m);
+ }
+ renderMembers();
+ await fetchPipes();
+ renderAllMessages();
+ } catch (err) {
+ console.error('[chat] Failed to load initial data:', err);
+ }
+}
+
+// ── Event binding ───────────────────────────────────────────────────
+
+function bindEvents() {
+ if (!_container) return;
+
+ _container.addEventListener('mouseover', onTooltipOver);
+ _container.addEventListener('mouseout', onTooltipOut);
+
+ _container.querySelector('#chat-send-btn')?.addEventListener('click', sendMessage);
+
+ const input = _container.querySelector('#chat-input');
+ if (input) {
+ input.addEventListener('keydown', onInputKeyDown);
+ input.addEventListener('input', onInputChange);
+ }
+
+ _container.querySelector('#chat-btn-clear')?.addEventListener('click', async () => {
+ await api('/messages', { method: 'DELETE' });
+ _messages = [];
+ _pipeEvents = [];
+ renderAllMessages();
+ });
+
+ _container.querySelector('#chat-btn-rules')?.addEventListener('click', openRulesEditor);
+ _container.querySelector('#chat-rules-close')?.addEventListener('click', closeRulesEditor);
+ _container.querySelector('#chat-rules-save')?.addEventListener('click', saveRules);
+ _container.querySelector('#chat-rules-reset')?.addEventListener('click', resetRules);
+ _container.querySelector('#chat-pipes-toggle')?.addEventListener('click', () => {
+ _pipesCollapsed = !_pipesCollapsed;
+ renderPipes();
+ });
+
+ _container.querySelector('#chat-rules-textarea')?.addEventListener('input', syncRulesDraftFromInput);
+ _container.querySelector('#chat-rules-overlay')?.addEventListener('click', (e) => {
+ if (e.target?.id === 'chat-rules-overlay') closeRulesEditor();
+ });
+
+ // Auto-scroll detection
+ const listEl = _container.querySelector('#chat-messages-list');
+ if (listEl) {
+ listEl.addEventListener('scroll', () => {
+ const threshold = 50;
+ _autoScroll = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - threshold;
+ if (_autoScroll) hideNewIndicator();
+ });
+ }
+
+ // New messages indicator click
+ _container.querySelector('#chat-new-indicator')?.addEventListener('click', scrollToBottom);
+}
+
+// ── Exports ─────────────────────────────────────────────────────────
+
+export function mount(container, ctx) {
+ _container = container;
+ _projectId = ctx?.project?.id || null;
+ _messages = [];
+ _pipeEvents = [];
+ _members = [];
+ _pipeSummaries = [];
+ _pipeLeases = [];
+ _pipeDeadLetters = [];
+ _pipeStatusById = {};
+ _pipeStatusLoading = new Set();
+ _pipeTimingById = {};
+ _pipeTimingLoading = new Set();
+ _pipesCollapsed = false;
+ _expandedPipeId = null;
+ _showAllPipes = false;
+ _autoScroll = true;
+ _rulesDraft = '';
+ _rulesLoaded = false;
+
+ container.classList.add('page-chat', 'app-page');
+ container.innerHTML = BODY_HTML;
+
+ // Load marked.js for markdown rendering (reuse kanban's vendored copy)
+ loadScript('/app/kanban/vendor/marked.min.js', 'marked')
+ .then(() => { initMarked(); renderAllMessages(); })
+ .catch(() => { /* graceful degradation — messages render as plain text */ });
+
+ // Load mermaid.js for chart rendering (CDN, ESM build)
+ loadScript('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js', 'mermaid')
+ .then(() => { initMermaid(); renderMermaidBlocks(); })
+ .catch(() => { _mermaidFailed = true; fallbackAllMermaidBlocks(); });
+
+ bindEvents();
+ loadInitialData();
+ connectSocket();
+ startPipePolling();
+ startLeaseCountdown();
+
+ // Voice STT — insert transcribed text into chat input
+ _voiceHandler = (e) => {
+ const text = e.detail?.text;
+ if (!text) return;
+ const input = container.querySelector('#chat-input');
+ if (!input) return;
+ const start = input.selectionStart ?? input.value.length;
+ const end = input.selectionEnd ?? input.value.length;
+ input.value = input.value.slice(0, start) + text + input.value.slice(end);
+ input.selectionStart = input.selectionEnd = start + text.length;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.focus();
+ };
+ document.addEventListener('voice:result', _voiceHandler);
+
+ // Restore draft text (scoped to current project)
+ const draft = sessionStorage.getItem(draftKey(_projectId));
+ if (draft) {
+ const input = container.querySelector('#chat-input');
+ if (input) {
+ input.value = draft;
+ autoResizeInput(input);
+ }
+ }
+
+ // Auto-focus the input when navigating to chat
+ container.querySelector('#chat-input')?.focus();
+}
+
+export function unmount(container) {
+ // Save draft text before teardown (scoped to current project)
+ const input = container.querySelector('#chat-input');
+ const key = draftKey(_projectId);
+ if (input?.value) {
+ sessionStorage.setItem(key, input.value);
+ } else {
+ sessionStorage.removeItem(key);
+ }
+
+ if (_voiceHandler) {
+ document.removeEventListener('voice:result', _voiceHandler);
+ _voiceHandler = null;
+ }
+ closeMentionPopup();
+ disconnectSocket();
+ stopPipePolling();
+ stopLeaseCountdown();
+ container.classList.remove('page-chat', 'app-page');
+ container.innerHTML = '';
+ _container = null;
+ _projectId = null;
+ _messages = [];
+ _pipeEvents = [];
+ _members = [];
+ _pipeSummaries = [];
+ _pipeLeases = [];
+ _pipeDeadLetters = [];
+ _pipeStatusById = {};
+ _pipeStatusLoading = new Set();
+ _pipeTimingById = {};
+ _pipeTimingLoading = new Set();
+ _pipesCollapsed = false;
+ _expandedPipeId = null;
+ _showAllPipes = false;
+ _brainstorms = {};
+ _rulesDraft = '';
+ _rulesLoaded = false;
+
+ _mermaidIdCounter = 0;
+ _mermaidFailed = false;
+}
+
+export function onProjectChange(project) {
+ // Save current project's draft before switching
+ const input = _container?.querySelector('#chat-input');
+ const oldKey = draftKey(_projectId);
+ if (input?.value) {
+ sessionStorage.setItem(oldKey, input.value);
+ } else {
+ sessionStorage.removeItem(oldKey);
+ }
+
+ _projectId = project?.id || null;
+ _messages = [];
+ _pipeEvents = [];
+ _members = [];
+ _pipeSummaries = [];
+ _pipeLeases = [];
+ _pipeDeadLetters = [];
+ _pipeStatusById = {};
+ _pipeStatusLoading = new Set();
+ _pipeTimingById = {};
+ _pipeTimingLoading = new Set();
+ _pipesCollapsed = false;
+ _expandedPipeId = null;
+ _showAllPipes = false;
+ _brainstorms = {};
+ _rulesDraft = '';
+ _rulesLoaded = false;
+
+ if (_container) {
+ // Restore the new project's draft (or clear)
+ const newDraft = sessionStorage.getItem(draftKey(_projectId));
+ if (input) {
+ input.value = newDraft || '';
+ autoResizeInput(input);
+ }
+ loadInitialData();
+ }
+}
diff --git a/src/apps/chat/public/pipe-visibility.js b/src/apps/chat/public/pipe-visibility.js
new file mode 100644
index 0000000..034ed24
--- /dev/null
+++ b/src/apps/chat/public/pipe-visibility.js
@@ -0,0 +1,52 @@
+export const DEFAULT_VISIBLE_TERMINAL = 10;
+
+export function getPipeStatusRank(status) {
+ return status === 'running' ? 0 : status === 'failed' ? 1 : status === 'cancelled' ? 2 : 3;
+}
+
+export function sortPipeSummaries(pipes) {
+ return [...pipes].sort((a, b) => {
+ const statusDelta = getPipeStatusRank(a.status) - getPipeStatusRank(b.status);
+ if (statusDelta !== 0) return statusDelta;
+ return String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
+ });
+}
+
+export function getVisiblePipeSummaries(
+ pipes,
+ {
+ expandedPipeId = null,
+ showAll = false,
+ terminalLimit = DEFAULT_VISIBLE_TERMINAL,
+ } = {},
+) {
+ const sorted = sortPipeSummaries(pipes);
+ const running = [];
+ const terminal = [];
+
+ for (const pipe of sorted) {
+ if (pipe.status === 'running') running.push(pipe);
+ else terminal.push(pipe);
+ }
+
+ const normalizedLimit = Number.isFinite(terminalLimit)
+ ? Math.max(0, Math.trunc(terminalLimit))
+ : DEFAULT_VISIBLE_TERMINAL;
+
+ const visibleTerminalIds = new Set(
+ showAll
+ ? terminal.map(pipe => pipe.pipeId)
+ : terminal.slice(0, normalizedLimit).map(pipe => pipe.pipeId),
+ );
+
+ if (expandedPipeId) visibleTerminalIds.add(expandedPipeId);
+
+ const visibleTerminal = terminal.filter(pipe => visibleTerminalIds.has(pipe.pipeId));
+ return {
+ visiblePipes: [...running, ...visibleTerminal],
+ hiddenTerminalCount: terminal.length - visibleTerminal.length,
+ totalCount: sorted.length,
+ totalTerminalCount: terminal.length,
+ canToggleTerminalHistory: terminal.length > normalizedLimit,
+ };
+}
diff --git a/src/apps/chat/public/pipe-visibility.test.js b/src/apps/chat/public/pipe-visibility.test.js
new file mode 100644
index 0000000..9ebbf7a
--- /dev/null
+++ b/src/apps/chat/public/pipe-visibility.test.js
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_VISIBLE_TERMINAL, getVisiblePipeSummaries, sortPipeSummaries } from './pipe-visibility.js';
+
+function pipe(pipeId, status, createdAt) {
+ return {
+ pipeId,
+ status,
+ createdAt,
+ slotSummary: { total: 1, submitted: 0, leased: 0, pending: 1 },
+ };
+}
+
+describe('sortPipeSummaries', () => {
+ it('keeps running pipes first, then terminal pipes by recency', () => {
+ const pipes = [
+ pipe('completed-old', 'completed', '2026-04-02T10:00:00.000Z'),
+ pipe('running-old', 'running', '2026-04-02T09:00:00.000Z'),
+ pipe('failed-new', 'failed', '2026-04-02T12:00:00.000Z'),
+ pipe('running-new', 'running', '2026-04-02T13:00:00.000Z'),
+ pipe('cancelled-new', 'cancelled', '2026-04-02T11:00:00.000Z'),
+ ];
+
+ expect(sortPipeSummaries(pipes).map(entry => entry.pipeId)).toEqual([
+ 'running-new',
+ 'running-old',
+ 'failed-new',
+ 'cancelled-new',
+ 'completed-old',
+ ]);
+ });
+});
+
+describe('getVisiblePipeSummaries', () => {
+ it('caps terminal pipes while keeping all running pipes visible', () => {
+ const pipes = [
+ pipe('running-a', 'running', '2026-04-02T15:00:00.000Z'),
+ pipe('running-b', 'running', '2026-04-02T14:00:00.000Z'),
+ pipe('completed-a', 'completed', '2026-04-02T13:00:00.000Z'),
+ pipe('completed-b', 'completed', '2026-04-02T12:00:00.000Z'),
+ pipe('failed-a', 'failed', '2026-04-02T11:00:00.000Z'),
+ ];
+
+ const result = getVisiblePipeSummaries(pipes, { terminalLimit: 2 });
+ expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([
+ 'running-a',
+ 'running-b',
+ 'failed-a',
+ 'completed-a',
+ ]);
+ expect(result.hiddenTerminalCount).toBe(1);
+ expect(result.canToggleTerminalHistory).toBe(true);
+ });
+
+ it('preserves an expanded terminal pipe outside the default cap', () => {
+ const pipes = [
+ pipe('completed-1', 'completed', '2026-04-02T15:00:00.000Z'),
+ pipe('completed-2', 'completed', '2026-04-02T14:00:00.000Z'),
+ pipe('completed-3', 'completed', '2026-04-02T13:00:00.000Z'),
+ ];
+
+ const result = getVisiblePipeSummaries(pipes, {
+ expandedPipeId: 'completed-3',
+ terminalLimit: 2,
+ });
+
+ expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([
+ 'completed-1',
+ 'completed-2',
+ 'completed-3',
+ ]);
+ expect(result.hiddenTerminalCount).toBe(0);
+ });
+
+ it('shows all terminal pipes when history is expanded', () => {
+ const pipes = [
+ pipe('completed-1', 'completed', '2026-04-02T15:00:00.000Z'),
+ pipe('completed-2', 'completed', '2026-04-02T14:00:00.000Z'),
+ pipe('completed-3', 'completed', '2026-04-02T13:00:00.000Z'),
+ ];
+
+ const result = getVisiblePipeSummaries(pipes, {
+ showAll: true,
+ terminalLimit: 1,
+ });
+
+ expect(result.visiblePipes.map(entry => entry.pipeId)).toEqual([
+ 'completed-1',
+ 'completed-2',
+ 'completed-3',
+ ]);
+ expect(result.hiddenTerminalCount).toBe(0);
+ });
+
+ it('uses the default terminal cap when none is supplied', () => {
+ const pipes = Array.from({ length: DEFAULT_VISIBLE_TERMINAL + 2 }, (_, index) =>
+ pipe(`completed-${index + 1}`, 'completed', `2026-04-02T${String(20 - index).padStart(2, '0')}:00:00.000Z`),
+ );
+
+ const result = getVisiblePipeSummaries(pipes);
+ expect(result.visiblePipes).toHaveLength(DEFAULT_VISIBLE_TERMINAL);
+ expect(result.hiddenTerminalCount).toBe(2);
+ });
+});
+
diff --git a/src/apps/chat/services/assignment-store.test.ts b/src/apps/chat/services/assignment-store.test.ts
new file mode 100644
index 0000000..7230787
--- /dev/null
+++ b/src/apps/chat/services/assignment-store.test.ts
@@ -0,0 +1,664 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as assignmentStore from './assignment-store.js';
+import { createTestClock } from './clock.js';
+
+beforeEach(() => {
+ assignmentStore._resetForTest();
+});
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function createTestAssignment(overrides?: {
+ pipeId?: string;
+ stageId?: string;
+ payloadId?: string;
+ assignee?: string;
+ role?: 'stage-output' | 'fan-out' | 'final';
+ stage?: number;
+}) {
+ return assignmentStore.createAssignment(
+ overrides?.pipeId ?? 'pipe-1',
+ overrides?.stageId ?? 'linear:1',
+ overrides?.payloadId ?? 'payload-1',
+ overrides?.assignee ?? 'alice',
+ overrides?.role ?? 'stage-output',
+ 'proj-1',
+ { stage: overrides?.stage ?? 1 },
+ );
+}
+
+/** Transition through the full happy path to 'submitted'. */
+function submitAssignment(assignmentId: string) {
+ assignmentStore.transitionAssignment(assignmentId, 'notified', 'proj-1');
+ assignmentStore.transitionAssignment(assignmentId, 'acknowledged', 'proj-1');
+ assignmentStore.transitionAssignment(assignmentId, 'payload_fetched', 'proj-1');
+ assignmentStore.transitionAssignment(assignmentId, 'submitted', 'proj-1');
+}
+
+// ── deriveStageId ────────────────────────────────────────────────────────────
+
+describe('deriveStageId', () => {
+ it('derives linear stage ID', () => {
+ expect(assignmentStore.deriveStageId('linear', 'stage-output', { stage: 3 })).toBe('linear:3');
+ });
+
+ it('derives fan-out stage ID', () => {
+ expect(assignmentStore.deriveStageId('merge', 'fan-out', { assignee: 'bob' })).toBe('fan-out:bob');
+ });
+
+ it('derives synth stage ID', () => {
+ expect(assignmentStore.deriveStageId('merge-all', 'final')).toBe('synth');
+ });
+});
+
+// ── createAssignment ─────────────────────────────────────────────────────────
+
+describe('createAssignment', () => {
+ it('creates an assignment with correct initial state', () => {
+ const result = createTestAssignment();
+ expect(result.ok).toBe(true);
+ expect(result.assignment).toBeDefined();
+
+ const a = result.assignment!;
+ expect(a.pipeId).toBe('pipe-1');
+ expect(a.stageId).toBe('linear:1');
+ expect(a.payloadId).toBe('payload-1');
+ expect(a.assignee).toBe('alice');
+ expect(a.role).toBe('stage-output');
+ expect(a.stage).toBe(1);
+ expect(a.status).toBe('assigned');
+ expect(a.attempt).toBe(1);
+ expect(a.version).toBe(1);
+ expect(a.supersededBy).toBeNull();
+ expect(a.supersedes).toBeNull();
+ });
+
+ it('sets all timestamps to null except createdAt', () => {
+ const result = createTestAssignment();
+ const a = result.assignment!;
+ expect(a.createdAt).toBeTruthy();
+ expect(a.notifiedAt).toBeNull();
+ expect(a.acknowledgedAt).toBeNull();
+ expect(a.fetchedAt).toBeNull();
+ expect(a.submittedAt).toBeNull();
+ expect(a.expiredAt).toBeNull();
+ expect(a.reassignedAt).toBeNull();
+ expect(a.cancelledAt).toBeNull();
+ });
+
+ it('rejects duplicate active assignment for same pipe+stageId', () => {
+ createTestAssignment();
+ const result = createTestAssignment({ assignee: 'bob' });
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('DUPLICATE_ACTIVE');
+ });
+
+ it('allows new assignment after previous one reaches terminal state', () => {
+ const first = createTestAssignment();
+ assignmentStore.transitionAssignment(first.assignment!.assignmentId, 'expired', 'proj-1');
+
+ const second = createTestAssignment({ assignee: 'bob' });
+ expect(second.ok).toBe(true);
+ expect(second.assignment!.assignee).toBe('bob');
+ });
+
+ it('increments attempt when superseding', () => {
+ const first = createTestAssignment();
+ assignmentStore.transitionAssignment(first.assignment!.assignmentId, 'expired', 'proj-1');
+
+ const second = assignmentStore.createAssignment(
+ 'pipe-1', 'linear:1', 'payload-1', 'bob', 'stage-output', 'proj-1',
+ { stage: 1, supersedes: first.assignment!.assignmentId },
+ );
+ expect(second.ok).toBe(true);
+ expect(second.assignment!.attempt).toBe(2);
+ expect(second.assignment!.supersedes).toBe(first.assignment!.assignmentId);
+ });
+});
+
+// ── transitionAssignment ─────────────────────────────────────────────────────
+
+describe('transitionAssignment', () => {
+ it('transitions through the happy path', () => {
+ const { assignment } = createTestAssignment();
+ const id = assignment!.assignmentId;
+
+ const r1 = assignmentStore.transitionAssignment(id, 'notified', 'proj-1');
+ expect(r1.ok).toBe(true);
+ expect(r1.assignment!.status).toBe('notified');
+ expect(r1.assignment!.notifiedAt).toBeTruthy();
+ expect(r1.assignment!.version).toBe(2);
+
+ const r2 = assignmentStore.transitionAssignment(id, 'acknowledged', 'proj-1');
+ expect(r2.ok).toBe(true);
+ expect(r2.assignment!.acknowledgedAt).toBeTruthy();
+
+ const r3 = assignmentStore.transitionAssignment(id, 'payload_fetched', 'proj-1');
+ expect(r3.ok).toBe(true);
+ expect(r3.assignment!.fetchedAt).toBeTruthy();
+
+ const r4 = assignmentStore.transitionAssignment(id, 'submitted', 'proj-1');
+ expect(r4.ok).toBe(true);
+ expect(r4.assignment!.submittedAt).toBeTruthy();
+ expect(r4.assignment!.version).toBe(5);
+ });
+
+ it('rejects invalid transition', () => {
+ const { assignment } = createTestAssignment();
+ const result = assignmentStore.transitionAssignment(
+ assignment!.assignmentId, 'submitted', 'proj-1',
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('INVALID_TRANSITION');
+ });
+
+ it('rejects transition on terminal assignment', () => {
+ const { assignment } = createTestAssignment();
+ const id = assignment!.assignmentId;
+ assignmentStore.transitionAssignment(id, 'expired', 'proj-1');
+
+ const result = assignmentStore.transitionAssignment(id, 'notified', 'proj-1');
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('ASSIGNMENT_TERMINAL');
+ });
+
+ it('returns error for unknown assignment', () => {
+ const result = assignmentStore.transitionAssignment('nonexistent', 'notified', 'proj-1');
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('ASSIGNMENT_NOT_FOUND');
+ });
+
+ it('checks optimistic concurrency version', () => {
+ const { assignment } = createTestAssignment();
+ const id = assignment!.assignmentId;
+
+ const result = assignmentStore.transitionAssignment(id, 'notified', 'proj-1', { expectedVersion: 99 });
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('VERSION_CONFLICT');
+
+ const ok = assignmentStore.transitionAssignment(id, 'notified', 'proj-1', { expectedVersion: 1 });
+ expect(ok.ok).toBe(true);
+ });
+
+ it('allows direct transition to expired from any non-terminal state', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1');
+
+ const result = assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.assignment!.expiredAt).toBeTruthy();
+ });
+
+ it('allows direct transition to cancelled from any non-terminal state', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1');
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'acknowledged', 'proj-1');
+
+ const result = assignmentStore.transitionAssignment(assignment!.assignmentId, 'cancelled', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.assignment!.cancelledAt).toBeTruthy();
+ });
+
+ it('removes from active index on terminal transition', () => {
+ const { assignment } = createTestAssignment();
+ const id = assignment!.assignmentId;
+
+ expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeDefined();
+
+ submitAssignment(id);
+
+ expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeUndefined();
+ });
+});
+
+// ── reassignAssignment ───────────────────────────────────────────────────────
+
+describe('reassignAssignment', () => {
+ it('reassigns to a new participant', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1');
+
+ const result = assignmentStore.reassignAssignment(
+ assignment!.assignmentId, 'bob', 'proj-1', 'participant left',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ // Old assignment is reassigned
+ expect(result.old.status).toBe('reassigned');
+ expect(result.old.reassignedAt).toBeTruthy();
+ expect(result.old.reassignReason).toBe('participant left');
+ expect(result.old.supersededBy).toBe(result.new.assignmentId);
+
+ // New assignment is created
+ expect(result.new.assignee).toBe('bob');
+ expect(result.new.status).toBe('assigned');
+ expect(result.new.attempt).toBe(2);
+ expect(result.new.supersedes).toBe(result.old.assignmentId);
+ expect(result.new.pipeId).toBe('pipe-1');
+ expect(result.new.stageId).toBe('linear:1');
+ });
+
+ it('rejects reassignment of terminal assignment', () => {
+ const { assignment } = createTestAssignment();
+ submitAssignment(assignment!.assignmentId);
+
+ const result = assignmentStore.reassignAssignment(
+ assignment!.assignmentId, 'bob', 'proj-1', 'test',
+ );
+ expect(result.ok).toBe(false);
+ });
+
+ it('new assignment becomes the active one', () => {
+ const { assignment } = createTestAssignment();
+ const result = assignmentStore.reassignAssignment(
+ assignment!.assignmentId, 'bob', 'proj-1', 'test',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ expect(active).toBeDefined();
+ expect(active!.assignee).toBe('bob');
+ });
+});
+
+// ── cancelPipeAssignments ────────────────────────────────────────────────────
+
+describe('cancelPipeAssignments', () => {
+ it('cancels all non-terminal assignments for a pipe', () => {
+ createTestAssignment({ stageId: 'linear:1', assignee: 'alice', stage: 1 });
+ // Submit the first so it's terminal
+ const first = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ submitAssignment(first!.assignmentId);
+
+ // Create a second that will be cancelled
+ createTestAssignment({ stageId: 'linear:2', assignee: 'bob', stage: 2 });
+
+ const cancelled = assignmentStore.cancelPipeAssignments('pipe-1', 'proj-1');
+ expect(cancelled).toHaveLength(1);
+
+ // The submitted one should not be affected
+ const submitted = assignmentStore.getAssignment(first!.assignmentId, 'proj-1');
+ expect(submitted!.status).toBe('submitted');
+
+ // The pending one should be cancelled
+ const bobAssignment = assignmentStore.getAssignmentsByPipe('pipe-1', 'proj-1')
+ .find(a => a.assignee === 'bob');
+ expect(bobAssignment!.status).toBe('cancelled');
+ });
+});
+
+// ── Queries ──────────────────────────────────────────────────────────────────
+
+describe('queries', () => {
+ it('getAssignment returns assignment by ID', () => {
+ const { assignment } = createTestAssignment();
+ const found = assignmentStore.getAssignment(assignment!.assignmentId, 'proj-1');
+ expect(found).toBeDefined();
+ expect(found!.assignmentId).toBe(assignment!.assignmentId);
+ });
+
+ it('getActiveAssignment returns the non-terminal assignment for a stage', () => {
+ createTestAssignment();
+ const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ expect(active).toBeDefined();
+ expect(active!.status).toBe('assigned');
+ });
+
+ it('getActiveAssignment returns undefined for terminal assignments', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1');
+ expect(assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1')).toBeUndefined();
+ });
+
+ it('getAssignmentsByPipe returns all assignments including terminal', () => {
+ createTestAssignment({ stageId: 'linear:1', assignee: 'alice', stage: 1 });
+ const first = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ assignmentStore.transitionAssignment(first!.assignmentId, 'expired', 'proj-1');
+
+ createTestAssignment({ stageId: 'linear:2', assignee: 'bob', stage: 2 });
+
+ const all = assignmentStore.getAssignmentsByPipe('pipe-1', 'proj-1');
+ expect(all).toHaveLength(2);
+ });
+
+ it('getActiveAssignmentsForParticipant lists pending work', () => {
+ createTestAssignment({ pipeId: 'pipe-1', stageId: 'linear:1', assignee: 'alice', stage: 1 });
+ createTestAssignment({ pipeId: 'pipe-2', stageId: 'linear:1', assignee: 'alice', stage: 1 });
+
+ const active = assignmentStore.getActiveAssignmentsForParticipant('alice', 'proj-1');
+ expect(active).toHaveLength(2);
+ });
+
+ it('getActiveAssignmentsForParticipant excludes terminal assignments', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1');
+
+ const active = assignmentStore.getActiveAssignmentsForParticipant('alice', 'proj-1');
+ expect(active).toHaveLength(0);
+ });
+});
+
+// ── Assignment chain ─────────────────────────────────────────────────────────
+
+describe('getAssignmentChain', () => {
+ it('returns single assignment when no chain', () => {
+ const { assignment } = createTestAssignment();
+ const chain = assignmentStore.getAssignmentChain(assignment!.assignmentId, 'proj-1');
+ expect(chain).toHaveLength(1);
+ expect(chain[0].assignmentId).toBe(assignment!.assignmentId);
+ });
+
+ it('returns full chain across reassignments', () => {
+ const { assignment: a1 } = createTestAssignment();
+ const r1 = assignmentStore.reassignAssignment(a1!.assignmentId, 'bob', 'proj-1', 'test');
+ expect(r1.ok).toBe(true);
+ if (!r1.ok) return;
+
+ const r2 = assignmentStore.reassignAssignment(r1.new.assignmentId, 'carol', 'proj-1', 'test2');
+ expect(r2.ok).toBe(true);
+ if (!r2.ok) return;
+
+ // Query from any point in the chain
+ const chain = assignmentStore.getAssignmentChain(r1.new.assignmentId, 'proj-1');
+ expect(chain).toHaveLength(3);
+ expect(chain[0].assignee).toBe('alice');
+ expect(chain[1].assignee).toBe('bob');
+ expect(chain[2].assignee).toBe('carol');
+ });
+});
+
+// ── Stale access ─────────────────────────────────────────────────────────────
+
+describe('stale access', () => {
+ it('isStale returns true for reassigned assignments', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.reassignAssignment(assignment!.assignmentId, 'bob', 'proj-1', 'test');
+ expect(assignmentStore.isStale(assignment!.assignmentId, 'proj-1')).toBe(true);
+ });
+
+ it('isStale returns false for active assignments', () => {
+ const { assignment } = createTestAssignment();
+ expect(assignmentStore.isStale(assignment!.assignmentId, 'proj-1')).toBe(false);
+ });
+
+ it('staleAccessPolicy returns accept-silent for reassigned', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.reassignAssignment(assignment!.assignmentId, 'bob', 'proj-1', 'test');
+ expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('accept-silent');
+ });
+
+ it('staleAccessPolicy returns reject for expired', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'expired', 'proj-1');
+ expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('reject');
+ });
+
+ it('staleAccessPolicy returns ok for active', () => {
+ const { assignment } = createTestAssignment();
+ expect(assignmentStore.staleAccessPolicy(assignment!.assignmentId, 'proj-1')).toBe('ok');
+ });
+});
+
+// ── toNotification ───────────────────────────────────────────────────────────
+
+describe('toNotification', () => {
+ it('produces a compact notification envelope', () => {
+ const { assignment } = createTestAssignment();
+ const notification = assignmentStore.toNotification(assignment!);
+
+ expect(notification.assignmentId).toBe(assignment!.assignmentId);
+ expect(notification.pipeId).toBe('pipe-1');
+ expect(notification.stageId).toBe('linear:1');
+ expect(notification.role).toBe('stage-output');
+ expect(notification.stage).toBe(1);
+ expect(notification.attempt).toBe(1);
+ expect(notification.payloadId).toBe('payload-1');
+ // Should NOT contain content, timestamps, or chain info
+ expect(notification).not.toHaveProperty('content');
+ expect(notification).not.toHaveProperty('createdAt');
+ expect(notification).not.toHaveProperty('supersededBy');
+ });
+});
+
+// ── Cleanup ──────────────────────────────────────────────────────────────────
+
+describe('cleanupTerminalAssignments', () => {
+ it('removes terminal assignments older than TTL', () => {
+ const clock = createTestClock();
+ assignmentStore.setClock(clock);
+
+ createTestAssignment();
+ const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ assignmentStore.transitionAssignment(active!.assignmentId, 'expired', 'proj-1');
+
+ // Not enough time has passed
+ clock.advance(1000);
+ expect(assignmentStore.cleanupTerminalAssignments('proj-1', 5000)).toBe(0);
+
+ // Now enough time has passed
+ clock.advance(5000);
+ expect(assignmentStore.cleanupTerminalAssignments('proj-1', 5000)).toBe(1);
+ });
+
+ it('does not remove active assignments', () => {
+ const clock = createTestClock();
+ assignmentStore.setClock(clock);
+
+ createTestAssignment();
+ clock.advance(100_000);
+ expect(assignmentStore.cleanupTerminalAssignments('proj-1', 1000)).toBe(0);
+ });
+});
+
+// ── Recovery ─────────────────────────────────────────────────────────────────
+
+describe('rehydrateFromEvents', () => {
+ it('recreates assignment from creation event', () => {
+ const events: assignmentStore.AssignmentRecoveryEvent[] = [
+ {
+ type: 'assignment-created',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ payloadId: 'payload-1',
+ assignee: 'alice',
+ role: 'stage-output',
+ stage: 1,
+ },
+ ];
+
+ const active = assignmentStore.rehydrateFromEvents(events, 'proj-1');
+ expect(active).toContain('a-001');
+
+ const assignment = assignmentStore.getAssignment('a-001', 'proj-1');
+ expect(assignment).toBeDefined();
+ expect(assignment!.assignee).toBe('alice');
+ expect(assignment!.status).toBe('assigned');
+ });
+
+ it('replays transitions', () => {
+ const events: assignmentStore.AssignmentRecoveryEvent[] = [
+ {
+ type: 'assignment-created',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ payloadId: 'payload-1',
+ assignee: 'alice',
+ role: 'stage-output',
+ stage: 1,
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'notified',
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'acknowledged',
+ },
+ ];
+
+ const active = assignmentStore.rehydrateFromEvents(events, 'proj-1');
+ expect(active).toContain('a-001');
+
+ const assignment = assignmentStore.getAssignment('a-001', 'proj-1');
+ expect(assignment!.status).toBe('acknowledged');
+ });
+
+ it('marks terminal assignments as not active', () => {
+ const events: assignmentStore.AssignmentRecoveryEvent[] = [
+ {
+ type: 'assignment-created',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ payloadId: 'payload-1',
+ assignee: 'alice',
+ role: 'stage-output',
+ stage: 1,
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'notified',
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'acknowledged',
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'payload_fetched',
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'submitted',
+ },
+ ];
+
+ const active = assignmentStore.rehydrateFromEvents(events, 'proj-1');
+ expect(active).not.toContain('a-001');
+ });
+});
+
+// ── retryAssignment ──────────────────────────────────────────────────────────
+
+describe('retryAssignment', () => {
+ it('creates a new attempt with same assignee, marks old as superseded', () => {
+ const { assignment } = createTestAssignment();
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1');
+
+ const result = assignmentStore.retryAssignment(
+ assignment!.assignmentId, 'proj-1', 'transient failure',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ // Old assignment is superseded (not reassigned)
+ expect(result.old.status).toBe('superseded');
+ expect(result.old.reassignReason).toBe('transient failure');
+ expect(result.old.supersededBy).toBe(result.new.assignmentId);
+
+ // New assignment has same assignee but incremented attempt
+ expect(result.new.assignee).toBe('alice'); // same assignee
+ expect(result.new.attempt).toBe(2);
+ expect(result.new.supersedes).toBe(result.old.assignmentId);
+ expect(result.new.status).toBe('assigned');
+ });
+
+ it('rejects retry of terminal assignment', () => {
+ const { assignment } = createTestAssignment();
+ submitAssignment(assignment!.assignmentId);
+
+ const result = assignmentStore.retryAssignment(
+ assignment!.assignmentId, 'proj-1', 'test',
+ );
+ expect(result.ok).toBe(false);
+ });
+
+ it('new retry becomes the active assignment', () => {
+ const { assignment } = createTestAssignment();
+ const result = assignmentStore.retryAssignment(
+ assignment!.assignmentId, 'proj-1', 'retry',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ const active = assignmentStore.getActiveAssignment('pipe-1', 'linear:1', 'proj-1');
+ expect(active).toBeDefined();
+ expect(active!.assignmentId).toBe(result.new.assignmentId);
+ expect(active!.assignee).toBe('alice');
+ });
+});
+
+// ── Recovery timestamp fidelity ──────────────────────────────────────────────
+
+describe('recovery timestamp fidelity', () => {
+ it('preserves original event timestamps during rehydration', () => {
+ const events: assignmentStore.AssignmentRecoveryEvent[] = [
+ {
+ type: 'assignment-created',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ payloadId: 'payload-1',
+ assignee: 'alice',
+ role: 'stage-output',
+ stage: 1,
+ ts: '2026-03-15T10:00:00.000Z',
+ },
+ {
+ type: 'assignment-transitioned',
+ assignmentId: 'a-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ status: 'notified',
+ ts: '2026-03-15T10:00:05.000Z',
+ },
+ ];
+
+ assignmentStore.rehydrateFromEvents(events, 'proj-1');
+ const assignment = assignmentStore.getAssignment('a-001', 'proj-1');
+
+ // Timestamps should match the persisted events, not the current clock
+ expect(assignment!.createdAt).toBe('2026-03-15T10:00:00.000Z');
+ expect(assignment!.notifiedAt).toBe('2026-03-15T10:00:05.000Z');
+ });
+});
+
+// ── Clock injection ──────────────────────────────────────────────────────────
+
+describe('clock injection', () => {
+ it('uses injected clock for timestamps', () => {
+ const clock = createTestClock(1700000000000); // 2023-11-14T22:13:20Z
+ assignmentStore.setClock(clock);
+
+ const { assignment } = createTestAssignment();
+ expect(assignment!.createdAt).toBe('2023-11-14T22:13:20.000Z');
+
+ clock.advance(5000);
+ assignmentStore.transitionAssignment(assignment!.assignmentId, 'notified', 'proj-1');
+
+ const updated = assignmentStore.getAssignment(assignment!.assignmentId, 'proj-1');
+ expect(updated!.notifiedAt).toBe('2023-11-14T22:13:25.000Z');
+ });
+});
diff --git a/src/apps/chat/services/assignment-store.ts b/src/apps/chat/services/assignment-store.ts
new file mode 100644
index 0000000..4a5ee45
--- /dev/null
+++ b/src/apps/chat/services/assignment-store.ts
@@ -0,0 +1,724 @@
+import { randomUUID } from 'crypto';
+import type { PipeMode, AssignmentStatus } from '../types.js';
+import { TERMINAL_ASSIGNMENT_STATUSES, ASSIGNMENT_TRANSITIONS } from '../types.js';
+import type { Clock } from './clock.js';
+import { systemClock } from './clock.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+/** A durable assignment representing a unit of work for a pipe stage.
+ * Assignments replace implicit one-shot delivery with a trackable entity
+ * that has its own lifecycle, survives reconnects, and supports reassignment. */
+export interface Assignment {
+ assignmentId: string; // stable UUID — immutable once created
+ pipeId: string;
+ stageId: string; // structured: "linear:1", "fan-out:alice", "synth"
+ payloadId: string; // references the authoritative payload in payload-store
+ assignee: string; // current participant name
+ role: 'stage-output' | 'fan-out' | 'final';
+ stage?: number; // 1-indexed for linear pipes
+ status: AssignmentStatus;
+ attempt: number; // starts at 1, increments on retry (same assignee)
+ version: number; // optimistic concurrency — increments on every mutation
+
+ // Timestamps (ISO 8601)
+ createdAt: string;
+ notifiedAt: string | null;
+ acknowledgedAt: string | null;
+ fetchedAt: string | null;
+ submittedAt: string | null;
+ expiredAt: string | null;
+ reassignedAt: string | null;
+ cancelledAt: string | null;
+
+ // Reassignment chain — links assignments that replace each other
+ supersededBy: string | null; // assignmentId of the replacement
+ supersedes: string | null; // assignmentId this replaces
+ reassignReason: string | null;
+}
+
+/** Error codes for assignment operations. */
+export type AssignmentErrorCode =
+ | 'ASSIGNMENT_NOT_FOUND'
+ | 'INVALID_TRANSITION'
+ | 'VERSION_CONFLICT'
+ | 'ASSIGNMENT_TERMINAL'
+ | 'DUPLICATE_ACTIVE';
+
+/** Result of an assignment operation. */
+export interface AssignmentResult {
+ ok: boolean;
+ error?: string;
+ code?: AssignmentErrorCode;
+ assignment?: Assignment;
+}
+
+/** Compact notification envelope — the minimal info sent via PTY instead of full payload. */
+export interface AssignmentNotification {
+ assignmentId: string;
+ pipeId: string;
+ stageId: string;
+ role: 'stage-output' | 'fan-out' | 'final';
+ stage?: number;
+ attempt: number;
+ payloadId: string;
+}
+
+// ── Storage ───────────────────────────────────────────────────────────────────
+
+// projectId -> (assignmentId -> Assignment)
+const stores = new Map
>();
+
+// projectId -> (pipeId:stageId -> assignmentId) — index for active assignment per stage
+const activeIndex = new Map>();
+
+// projectId -> (assigneeName -> Set) — index for assignments per participant
+const participantIndex = new Map>>();
+
+let clock: Clock = systemClock;
+
+/** Override the clock used for timestamps (for testing). */
+export function setClock(c: Clock): void {
+ clock = c;
+}
+
+function getProjectStore(projectId: string | null): Map {
+ let store = stores.get(projectId);
+ if (!store) { store = new Map(); stores.set(projectId, store); }
+ return store;
+}
+
+function getActiveIndex(projectId: string | null): Map {
+ let index = activeIndex.get(projectId);
+ if (!index) { index = new Map(); activeIndex.set(projectId, index); }
+ return index;
+}
+
+function getParticipantIndex(projectId: string | null): Map> {
+ let index = participantIndex.get(projectId);
+ if (!index) { index = new Map(); participantIndex.set(projectId, index); }
+ return index;
+}
+
+function activeKey(pipeId: string, stageId: string): string {
+ return `${pipeId}:${stageId}`;
+}
+
+function addToParticipantIndex(assignee: string, assignmentId: string, projectId: string | null): void {
+ const index = getParticipantIndex(projectId);
+ let ids = index.get(assignee);
+ if (!ids) { ids = new Set(); index.set(assignee, ids); }
+ ids.add(assignmentId);
+}
+
+function removeFromParticipantIndex(assignee: string, assignmentId: string, projectId: string | null): void {
+ const index = getParticipantIndex(projectId);
+ const ids = index.get(assignee);
+ if (ids) {
+ ids.delete(assignmentId);
+ if (ids.size === 0) index.delete(assignee);
+ }
+}
+
+// ── Stage ID derivation ───────────────────────────────────────────────────────
+
+/** Derive a structured stageId from pipe mode and role.
+ * Format: "linear:", "fan-out:", "synth" */
+export function deriveStageId(
+ mode: PipeMode,
+ role: 'stage-output' | 'fan-out' | 'final',
+ opts?: { stage?: number; assignee?: string },
+): string {
+ if (mode === 'linear') return `linear:${opts?.stage ?? 0}`;
+ if (role === 'fan-out') return `fan-out:${opts?.assignee ?? 'unknown'}`;
+ return 'synth';
+}
+
+// ── Assignment lifecycle ──────────────────────────────────────────────────────
+
+/** Create a new assignment for a pipe stage.
+ * If an active assignment already exists for this pipe+stageId, returns an error
+ * unless it has been superseded/reassigned. */
+export function createAssignment(
+ pipeId: string,
+ stageId: string,
+ payloadId: string,
+ assignee: string,
+ role: 'stage-output' | 'fan-out' | 'final',
+ projectId: string | null,
+ opts?: { stage?: number; supersedes?: string },
+): AssignmentResult {
+ const store = getProjectStore(projectId);
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(pipeId, stageId);
+
+ // Check for existing active assignment on this stage
+ const existingId = aIndex.get(key);
+ if (existingId) {
+ const existing = store.get(existingId);
+ if (existing && !TERMINAL_ASSIGNMENT_STATUSES.has(existing.status)) {
+ return {
+ ok: false,
+ code: 'DUPLICATE_ACTIVE',
+ error: `Active assignment ${existingId} already exists for ${key}`,
+ };
+ }
+ }
+
+ const attempt = opts?.supersedes
+ ? (store.get(opts.supersedes)?.attempt ?? 0) + 1
+ : 1;
+
+ const assignment: Assignment = {
+ assignmentId: randomUUID(),
+ pipeId,
+ stageId,
+ payloadId,
+ assignee,
+ role,
+ stage: opts?.stage,
+ status: 'assigned',
+ attempt,
+ version: 1,
+ createdAt: clock.isoNow(),
+ notifiedAt: null,
+ acknowledgedAt: null,
+ fetchedAt: null,
+ submittedAt: null,
+ expiredAt: null,
+ reassignedAt: null,
+ cancelledAt: null,
+ supersededBy: null,
+ supersedes: opts?.supersedes ?? null,
+ reassignReason: null,
+ };
+
+ store.set(assignment.assignmentId, assignment);
+ aIndex.set(key, assignment.assignmentId);
+ addToParticipantIndex(assignee, assignment.assignmentId, projectId);
+
+ return { ok: true, assignment: { ...assignment } };
+}
+
+/** Transition an assignment to a new status.
+ * Validates the transition against the state machine and increments the version.
+ * Optionally checks the expected version for optimistic concurrency. */
+export function transitionAssignment(
+ assignmentId: string,
+ newStatus: AssignmentStatus,
+ projectId: string | null,
+ opts?: { expectedVersion?: number },
+): AssignmentResult {
+ const store = getProjectStore(projectId);
+ const assignment = store.get(assignmentId);
+ if (!assignment) {
+ return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` };
+ }
+
+ if (TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) {
+ return {
+ ok: false,
+ code: 'ASSIGNMENT_TERMINAL',
+ error: `Assignment ${assignmentId} is in terminal status '${assignment.status}'`,
+ };
+ }
+
+ const allowed = ASSIGNMENT_TRANSITIONS[assignment.status];
+ if (!allowed.includes(newStatus)) {
+ return {
+ ok: false,
+ code: 'INVALID_TRANSITION',
+ error: `Cannot transition from '${assignment.status}' to '${newStatus}'`,
+ };
+ }
+
+ if (opts?.expectedVersion !== undefined && opts.expectedVersion !== assignment.version) {
+ return {
+ ok: false,
+ code: 'VERSION_CONFLICT',
+ error: `Version conflict: expected ${opts.expectedVersion}, actual ${assignment.version}`,
+ };
+ }
+
+ const now = clock.isoNow();
+ assignment.status = newStatus;
+ assignment.version++;
+
+ // Set the corresponding timestamp
+ switch (newStatus) {
+ case 'notified': assignment.notifiedAt = now; break;
+ case 'acknowledged': assignment.acknowledgedAt = now; break;
+ case 'payload_fetched': assignment.fetchedAt = now; break;
+ case 'submitted': assignment.submittedAt = now; break;
+ case 'expired': assignment.expiredAt = now; break;
+ case 'reassigned': assignment.reassignedAt = now; break;
+ case 'cancelled': assignment.cancelledAt = now; break;
+ }
+
+ // On terminal transition, remove from active index if this is the active assignment
+ if (TERMINAL_ASSIGNMENT_STATUSES.has(newStatus)) {
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(assignment.pipeId, assignment.stageId);
+ if (aIndex.get(key) === assignmentId) {
+ aIndex.delete(key);
+ }
+ }
+
+ return { ok: true, assignment: { ...assignment } };
+}
+
+/** Reassign an assignment to a different participant.
+ * Marks the current assignment as 'reassigned' and creates a new one for the new assignee.
+ * Returns both the old (reassigned) and new assignments. */
+export function reassignAssignment(
+ assignmentId: string,
+ newAssignee: string,
+ projectId: string | null,
+ reason: string,
+): { ok: true; old: Assignment; new: Assignment } | { ok: false; error: string; code?: AssignmentErrorCode } {
+ const store = getProjectStore(projectId);
+ const old = store.get(assignmentId);
+ if (!old) {
+ return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` };
+ }
+
+ if (TERMINAL_ASSIGNMENT_STATUSES.has(old.status)) {
+ return {
+ ok: false,
+ code: 'ASSIGNMENT_TERMINAL',
+ error: `Assignment ${assignmentId} is in terminal status '${old.status}'`,
+ };
+ }
+
+ // Mark the old assignment as reassigned
+ const now = clock.isoNow();
+ old.status = 'reassigned';
+ old.reassignedAt = now;
+ old.reassignReason = reason;
+ old.version++;
+
+ // Remove from active index
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(old.pipeId, old.stageId);
+ aIndex.delete(key);
+
+ // Create the replacement assignment
+ const result = createAssignment(
+ old.pipeId,
+ old.stageId,
+ old.payloadId,
+ newAssignee,
+ old.role,
+ projectId,
+ { stage: old.stage, supersedes: old.assignmentId },
+ );
+
+ if (!result.ok || !result.assignment) {
+ // Roll back the old assignment
+ old.status = 'assigned'; // restore — safe because we haven't updated chain yet
+ old.reassignedAt = null;
+ old.reassignReason = null;
+ old.version--;
+ aIndex.set(key, old.assignmentId);
+ return { ok: false, error: result.error ?? 'Failed to create replacement assignment' };
+ }
+
+ // Link the chain
+ old.supersededBy = result.assignment.assignmentId;
+ const newAssignment = store.get(result.assignment.assignmentId)!;
+ newAssignment.supersedes = old.assignmentId;
+
+ return {
+ ok: true,
+ old: { ...old },
+ new: { ...newAssignment },
+ };
+}
+
+/** Retry an assignment with the same assignee (e.g., after a transient failure).
+ * Marks the current assignment as 'superseded' and creates a new one with incremented attempt.
+ * Unlike reassignAssignment, the assignee stays the same — this is a same-agent retry. */
+export function retryAssignment(
+ assignmentId: string,
+ projectId: string | null,
+ reason: string,
+): { ok: true; old: Assignment; new: Assignment } | { ok: false; error: string; code?: AssignmentErrorCode } {
+ const store = getProjectStore(projectId);
+ const old = store.get(assignmentId);
+ if (!old) {
+ return { ok: false, code: 'ASSIGNMENT_NOT_FOUND', error: `Assignment ${assignmentId} not found` };
+ }
+
+ if (TERMINAL_ASSIGNMENT_STATUSES.has(old.status)) {
+ return {
+ ok: false,
+ code: 'ASSIGNMENT_TERMINAL',
+ error: `Assignment ${assignmentId} is in terminal status '${old.status}'`,
+ };
+ }
+
+ // Mark the old assignment as superseded
+ const now = clock.isoNow();
+ old.status = 'superseded';
+ old.reassignReason = reason;
+ old.version++;
+
+ // Remove from active index
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(old.pipeId, old.stageId);
+ aIndex.delete(key);
+
+ // Create the replacement assignment for the same assignee
+ const result = createAssignment(
+ old.pipeId,
+ old.stageId,
+ old.payloadId,
+ old.assignee, // same assignee — this is a retry, not a reassignment
+ old.role,
+ projectId,
+ { stage: old.stage, supersedes: old.assignmentId },
+ );
+
+ if (!result.ok || !result.assignment) {
+ // Roll back
+ old.status = 'assigned';
+ old.reassignReason = null;
+ old.version--;
+ aIndex.set(key, old.assignmentId);
+ return { ok: false, error: result.error ?? 'Failed to create retry assignment' };
+ }
+
+ // Link the chain
+ old.supersededBy = result.assignment.assignmentId;
+ const newAssignment = store.get(result.assignment.assignmentId)!;
+ newAssignment.supersedes = old.assignmentId;
+
+ return {
+ ok: true,
+ old: { ...old },
+ new: { ...newAssignment },
+ };
+}
+
+/** Cancel all non-terminal assignments for a pipe.
+ * Called when a pipe is cancelled or failed. Returns cancelled assignmentIds. */
+export function cancelPipeAssignments(pipeId: string, projectId: string | null): string[] {
+ const store = getProjectStore(projectId);
+ const cancelled: string[] = [];
+
+ for (const assignment of store.values()) {
+ if (assignment.pipeId !== pipeId) continue;
+ if (TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) continue;
+
+ assignment.status = 'cancelled';
+ assignment.cancelledAt = clock.isoNow();
+ assignment.version++;
+ cancelled.push(assignment.assignmentId);
+
+ // Remove from active index
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(assignment.pipeId, assignment.stageId);
+ if (aIndex.get(key) === assignment.assignmentId) {
+ aIndex.delete(key);
+ }
+ }
+
+ return cancelled;
+}
+
+// ── Queries ───────────────────────────────────────────────────────────────────
+
+/** Get an assignment by ID. */
+export function getAssignment(assignmentId: string, projectId: string | null): Assignment | undefined {
+ return getProjectStore(projectId).get(assignmentId);
+}
+
+/** Get the currently active (non-terminal) assignment for a pipe stage.
+ * Returns undefined if no active assignment exists. */
+export function getActiveAssignment(
+ pipeId: string,
+ stageId: string,
+ projectId: string | null,
+): Assignment | undefined {
+ const aIndex = getActiveIndex(projectId);
+ const id = aIndex.get(activeKey(pipeId, stageId));
+ if (!id) return undefined;
+ const assignment = getProjectStore(projectId).get(id);
+ if (!assignment || TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) return undefined;
+ return assignment;
+}
+
+/** List all assignments for a pipe (including terminal ones for audit trail). */
+export function getAssignmentsByPipe(pipeId: string, projectId: string | null): Assignment[] {
+ const store = getProjectStore(projectId);
+ const result: Assignment[] = [];
+ for (const assignment of store.values()) {
+ if (assignment.pipeId === pipeId) result.push(assignment);
+ }
+ return result;
+}
+
+/** Get all active (non-terminal) assignments for a participant.
+ * Used for reconnect recovery — the participant can see what work is pending. */
+export function getActiveAssignmentsForParticipant(
+ assignee: string,
+ projectId: string | null,
+): Assignment[] {
+ const pIndex = getParticipantIndex(projectId);
+ const ids = pIndex.get(assignee);
+ if (!ids) return [];
+ const store = getProjectStore(projectId);
+ const result: Assignment[] = [];
+ for (const id of ids) {
+ const assignment = store.get(id);
+ if (assignment && !TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) {
+ result.push(assignment);
+ }
+ }
+ return result;
+}
+
+/** Get the full reassignment chain for an assignment (oldest first).
+ * Follows the supersedes chain backward to the original assignment. */
+export function getAssignmentChain(assignmentId: string, projectId: string | null): Assignment[] {
+ const store = getProjectStore(projectId);
+ const chain: Assignment[] = [];
+
+ // Walk backward to find the root
+ let current = store.get(assignmentId);
+ const visited = new Set();
+ while (current && !visited.has(current.assignmentId)) {
+ visited.add(current.assignmentId);
+ chain.unshift(current);
+ if (current.supersedes) {
+ current = store.get(current.supersedes);
+ } else {
+ break;
+ }
+ }
+
+ // Walk forward from root to find any successors not yet in the chain
+ let last = chain[chain.length - 1];
+ while (last?.supersededBy) {
+ const next = store.get(last.supersededBy);
+ if (!next || visited.has(next.assignmentId)) break;
+ visited.add(next.assignmentId);
+ chain.push(next);
+ last = next;
+ }
+
+ return chain;
+}
+
+/** Build a compact notification envelope from an assignment. */
+export function toNotification(assignment: Assignment): AssignmentNotification {
+ return {
+ assignmentId: assignment.assignmentId,
+ pipeId: assignment.pipeId,
+ stageId: assignment.stageId,
+ role: assignment.role,
+ stage: assignment.stage,
+ attempt: assignment.attempt,
+ payloadId: assignment.payloadId,
+ };
+}
+
+/** Check whether an assignment is stale (has been reassigned or superseded).
+ * Stale assignments can still be read but not progressed. */
+export function isStale(assignmentId: string, projectId: string | null): boolean {
+ const assignment = getProjectStore(projectId).get(assignmentId);
+ if (!assignment) return true;
+ return assignment.status === 'reassigned' || assignment.status === 'superseded';
+}
+
+/** Check if a fetch/ack on a stale assignment should be silently accepted or rejected.
+ * After reassignment: ack/fetch are silently dropped (no error to the client).
+ * After cancel/expire: rejected with an error. */
+export function staleAccessPolicy(
+ assignmentId: string,
+ projectId: string | null,
+): 'accept-silent' | 'reject' | 'ok' {
+ const assignment = getProjectStore(projectId).get(assignmentId);
+ if (!assignment) return 'reject';
+ if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) return 'ok';
+ if (assignment.status === 'reassigned' || assignment.status === 'superseded') return 'accept-silent';
+ return 'reject';
+}
+
+// ── Cleanup ───────────────────────────────────────────────────────────────────
+
+/** Default retention for terminal assignments: 24 hours. */
+export const DEFAULT_ASSIGNMENT_TTL_MS = 24 * 60 * 60 * 1000;
+
+/** Remove terminal assignments older than the given TTL.
+ * Returns the number of assignments removed. */
+export function cleanupTerminalAssignments(
+ projectId: string | null,
+ ttlMs: number = DEFAULT_ASSIGNMENT_TTL_MS,
+): number {
+ const store = getProjectStore(projectId);
+ const now = clock.now();
+ let removed = 0;
+
+ for (const [id, assignment] of store) {
+ if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) continue;
+
+ // Use the terminal timestamp for TTL calculation
+ const terminalTs = assignment.submittedAt
+ ?? assignment.expiredAt
+ ?? assignment.reassignedAt
+ ?? assignment.cancelledAt
+ ?? assignment.createdAt;
+
+ if (now - new Date(terminalTs).getTime() >= ttlMs) {
+ store.delete(id);
+ removeFromParticipantIndex(assignment.assignee, id, projectId);
+ removed++;
+ }
+ }
+
+ return removed;
+}
+
+/** Get all projectIds that have assignment data in the store. */
+export function getTrackedProjectIds(): Array {
+ return [...stores.keys()];
+}
+
+// ── Recovery ──────────────────────────────────────────────────────────────────
+
+/** Assignment recovery event — persisted to JSONL for rehydration. */
+export interface AssignmentRecoveryEvent {
+ type: 'assignment-created' | 'assignment-transitioned' | 'assignment-reassigned' | 'assignment-cancelled';
+ assignmentId: string;
+ pipeId: string;
+ stageId: string;
+ payloadId?: string;
+ assignee?: string;
+ role?: 'stage-output' | 'fan-out' | 'final';
+ stage?: number;
+ status?: AssignmentStatus;
+ attempt?: number;
+ supersedes?: string;
+ newAssignee?: string;
+ reason?: string;
+ ts?: string;
+}
+
+/** Restore persisted timestamps and version on a recovered assignment.
+ * Mutators stamp fresh timestamps during replay — this overwrites them
+ * with the original event timestamps so TTL, audit, and recovery semantics
+ * remain faithful to the original timeline. */
+function restoreEventTimestamp(
+ assignmentId: string,
+ projectId: string | null,
+ status: AssignmentStatus,
+ ts: string,
+ version?: number,
+): void {
+ const assignment = getProjectStore(projectId).get(assignmentId);
+ if (!assignment) return;
+
+ switch (status) {
+ case 'assigned': assignment.createdAt = ts; break;
+ case 'notified': assignment.notifiedAt = ts; break;
+ case 'acknowledged': assignment.acknowledgedAt = ts; break;
+ case 'payload_fetched': assignment.fetchedAt = ts; break;
+ case 'submitted': assignment.submittedAt = ts; break;
+ case 'expired': assignment.expiredAt = ts; break;
+ case 'reassigned': assignment.reassignedAt = ts; break;
+ case 'cancelled': assignment.cancelledAt = ts; break;
+ }
+ if (version !== undefined) assignment.version = version;
+}
+
+/** Rehydrate assignment state from persisted events.
+ * Called on server restart. Preserves original event timestamps for TTL
+ * and audit fidelity. Returns assignmentIds that are still active. */
+export function rehydrateFromEvents(
+ events: AssignmentRecoveryEvent[],
+ projectId: string | null,
+): string[] {
+ const active: string[] = [];
+
+ for (const event of events) {
+ switch (event.type) {
+ case 'assignment-created': {
+ if (!event.payloadId || !event.assignee || !event.role) break;
+ createAssignment(
+ event.pipeId,
+ event.stageId,
+ event.payloadId,
+ event.assignee,
+ event.role,
+ projectId,
+ { stage: event.stage, supersedes: event.supersedes },
+ );
+ // Restore the assignmentId to match the persisted one
+ const store = getProjectStore(projectId);
+ const aIndex = getActiveIndex(projectId);
+ const key = activeKey(event.pipeId, event.stageId);
+ const generatedId = aIndex.get(key);
+ if (generatedId && generatedId !== event.assignmentId) {
+ const assignment = store.get(generatedId);
+ if (assignment) {
+ store.delete(generatedId);
+ assignment.assignmentId = event.assignmentId;
+ store.set(event.assignmentId, assignment);
+ aIndex.set(key, event.assignmentId);
+ // Fix participant index
+ removeFromParticipantIndex(event.assignee, generatedId, projectId);
+ addToParticipantIndex(event.assignee, event.assignmentId, projectId);
+ }
+ }
+ // Restore original creation timestamp
+ if (event.ts) {
+ restoreEventTimestamp(event.assignmentId, projectId, 'assigned', event.ts, event.attempt);
+ }
+ break;
+ }
+ case 'assignment-transitioned': {
+ if (!event.status) break;
+ transitionAssignment(event.assignmentId, event.status, projectId);
+ // Restore original event timestamp — overwrite the fresh one stamped by transitionAssignment
+ if (event.ts) {
+ restoreEventTimestamp(event.assignmentId, projectId, event.status, event.ts);
+ }
+ break;
+ }
+ case 'assignment-reassigned': {
+ if (!event.newAssignee) break;
+ reassignAssignment(event.assignmentId, event.newAssignee, projectId, event.reason ?? 'recovery');
+ // Restore original reassignment timestamp on the old assignment
+ if (event.ts) {
+ restoreEventTimestamp(event.assignmentId, projectId, 'reassigned', event.ts);
+ }
+ break;
+ }
+ case 'assignment-cancelled': {
+ cancelPipeAssignments(event.pipeId, projectId);
+ break;
+ }
+ }
+ }
+
+ // Collect active assignments
+ const store = getProjectStore(projectId);
+ for (const assignment of store.values()) {
+ if (!TERMINAL_ASSIGNMENT_STATUSES.has(assignment.status)) {
+ active.push(assignment.assignmentId);
+ }
+ }
+
+ return active;
+}
+
+// ── Test helper ───────────────────────────────────────────────────────────────
+
+/** Reset all in-memory state. For testing only. */
+export function _resetForTest(): void {
+ stores.clear();
+ activeIndex.clear();
+ participantIndex.clear();
+ clock = systemClock;
+}
diff --git a/src/apps/chat/services/brainstorm-store.test.ts b/src/apps/chat/services/brainstorm-store.test.ts
new file mode 100644
index 0000000..aa26077
--- /dev/null
+++ b/src/apps/chat/services/brainstorm-store.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+import {
+ createBrainstorm,
+ getBrainstorm,
+ updateBrainstorm,
+ listActiveBrainstorms,
+ linkChildPipe,
+ findBrainstormByChildPipe,
+ _resetForTest,
+} from './brainstorm-store.js';
+
+describe('brainstorm-store', () => {
+ beforeEach(() => {
+ _resetForTest();
+ });
+
+ it('creates and retrieves a brainstorm record', () => {
+ const record = createBrainstorm('bs1', ['alice', 'bob'], 'design a cache', 'proj1');
+ expect(record.id).toBe('bs1');
+ expect(record.phase).toBe('ideas');
+ expect(record.assignees).toEqual(['alice', 'bob']);
+ expect(record.prompt).toBe('design a cache');
+ expect(record.candidateIdea).toBeNull();
+ expect(record.acceptedIdea).toBeNull();
+ expect(record.candidateDraft).toBeNull();
+ expect(record.acceptedDraft).toBeNull();
+
+ const retrieved = getBrainstorm('bs1', 'proj1');
+ expect(retrieved).toBe(record); // same reference
+ });
+
+ it('returns undefined for unknown brainstorm', () => {
+ expect(getBrainstorm('nonexistent', 'proj1')).toBeUndefined();
+ });
+
+ it('updates a brainstorm record', () => {
+ createBrainstorm('bs1', ['alice', 'bob'], 'design a cache', 'proj1');
+ const updated = updateBrainstorm('bs1', 'proj1', { phase: 'ideas_review', candidateIdea: 'great idea' });
+ expect(updated?.phase).toBe('ideas_review');
+ expect(updated?.candidateIdea).toBe('great idea');
+ });
+
+ it('lists only active (non-complete) brainstorms', () => {
+ createBrainstorm('bs1', ['a', 'b'], 'topic1', 'proj1');
+ createBrainstorm('bs2', ['a', 'b'], 'topic2', 'proj1');
+ updateBrainstorm('bs1', 'proj1', { phase: 'complete' });
+
+ const active = listActiveBrainstorms('proj1');
+ expect(active).toHaveLength(1);
+ expect(active[0].id).toBe('bs2');
+ });
+
+ it('scopes brainstorms by project', () => {
+ createBrainstorm('bs1', ['a', 'b'], 'topic1', 'proj1');
+ createBrainstorm('bs2', ['a', 'b'], 'topic2', 'proj2');
+
+ expect(getBrainstorm('bs1', 'proj1')).toBeDefined();
+ expect(getBrainstorm('bs1', 'proj2')).toBeUndefined();
+ expect(listActiveBrainstorms('proj1')).toHaveLength(1);
+ expect(listActiveBrainstorms('proj2')).toHaveLength(1);
+ });
+
+ it('links and finds child pipes', () => {
+ createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1');
+ linkChildPipe('bs1', 'pipe-123', 'proj1');
+
+ const found = findBrainstormByChildPipe('pipe-123', 'proj1');
+ expect(found).toBeDefined();
+ expect(found?.id).toBe('bs1');
+ });
+
+ it('returns undefined for unlinked child pipe', () => {
+ expect(findBrainstormByChildPipe('pipe-unknown', 'proj1')).toBeUndefined();
+ });
+
+ it('child pipe lookup is project-scoped', () => {
+ createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1');
+ linkChildPipe('bs1', 'pipe-123', 'proj1');
+
+ expect(findBrainstormByChildPipe('pipe-123', 'proj1')).toBeDefined();
+ expect(findBrainstormByChildPipe('pipe-123', 'proj2')).toBeUndefined();
+ });
+
+ it('_resetForTest clears all state including child pipe map', () => {
+ createBrainstorm('bs1', ['a', 'b'], 'topic', 'proj1');
+ linkChildPipe('bs1', 'pipe-123', 'proj1');
+ _resetForTest();
+
+ expect(getBrainstorm('bs1', 'proj1')).toBeUndefined();
+ expect(findBrainstormByChildPipe('pipe-123', 'proj1')).toBeUndefined();
+ });
+});
diff --git a/src/apps/chat/services/brainstorm-store.ts b/src/apps/chat/services/brainstorm-store.ts
new file mode 100644
index 0000000..3be9f5a
--- /dev/null
+++ b/src/apps/chat/services/brainstorm-store.ts
@@ -0,0 +1,111 @@
+// ── Brainstorm state (chat-local) ─────────────────────────────────────────────
+// Thin workflow wrapper over existing merge-all and linear pipes.
+// Tracks phase progression and user decisions — does NOT touch pipe reducer/store.
+
+export type BrainstormPhase =
+ | 'ideas' // merge-all child pipe running
+ | 'ideas_review' // waiting for user to accept/retry the idea
+ | 'details' // linear child pipe pass running
+ | 'details_review' // waiting for user to accept/adjust/finalize the detail pass
+ | 'finalizing' // final pass running
+ | 'complete'; // done
+
+export interface BrainstormRecord {
+ id: string;
+ assignees: string[];
+ prompt: string;
+ phase: BrainstormPhase;
+ activeChildPipeId: string | null;
+ candidateIdea: string | null;
+ acceptedIdea: string | null;
+ candidateDraft: string | null;
+ acceptedDraft: string | null;
+ latestUserNote: string | null;
+ ideaIterations: number;
+ detailIterations: number;
+ createdAt: string;
+}
+
+// projectId -> (brainstormId -> BrainstormRecord)
+const stores = new Map>();
+
+function getProjectStore(projectId: string | null): Map {
+ let store = stores.get(projectId);
+ if (!store) {
+ store = new Map();
+ stores.set(projectId, store);
+ }
+ return store;
+}
+
+export function createBrainstorm(
+ id: string,
+ assignees: string[],
+ prompt: string,
+ projectId: string | null,
+): BrainstormRecord {
+ const store = getProjectStore(projectId);
+ const record: BrainstormRecord = {
+ id,
+ assignees,
+ prompt,
+ phase: 'ideas',
+ activeChildPipeId: null,
+ candidateIdea: null,
+ acceptedIdea: null,
+ candidateDraft: null,
+ acceptedDraft: null,
+ latestUserNote: null,
+ ideaIterations: 0,
+ detailIterations: 0,
+ createdAt: new Date().toISOString(),
+ };
+ store.set(id, record);
+ return record;
+}
+
+export function getBrainstorm(id: string, projectId: string | null): BrainstormRecord | undefined {
+ return getProjectStore(projectId).get(id);
+}
+
+export function updateBrainstorm(
+ id: string,
+ projectId: string | null,
+ updates: Partial>,
+): BrainstormRecord | undefined {
+ const record = getBrainstorm(id, projectId);
+ if (!record) return undefined;
+ Object.assign(record, updates);
+ return record;
+}
+
+export function listActiveBrainstorms(projectId: string | null): BrainstormRecord[] {
+ const store = getProjectStore(projectId);
+ return [...store.values()].filter(r => r.phase !== 'complete');
+}
+
+// ── Child pipe → brainstorm mapping ──────────────────────────────────────────
+// Tracks which child pipes belong to which brainstorm records.
+
+// "projectId:childPipeId" → brainstormId
+const childPipeMap = new Map();
+
+function childPipeKey(childPipeId: string, projectId: string | null): string {
+ return `${projectId ?? '__none__'}:${childPipeId}`;
+}
+
+export function linkChildPipe(brainstormId: string, childPipeId: string, projectId: string | null): void {
+ childPipeMap.set(childPipeKey(childPipeId, projectId), brainstormId);
+}
+
+export function findBrainstormByChildPipe(childPipeId: string, projectId: string | null): BrainstormRecord | undefined {
+ const brainstormId = childPipeMap.get(childPipeKey(childPipeId, projectId));
+ if (!brainstormId) return undefined;
+ return getBrainstorm(brainstormId, projectId);
+}
+
+/** Reset all in-memory state. For testing only. */
+export function _resetForTest(): void {
+ stores.clear();
+ childPipeMap.clear();
+}
diff --git a/src/apps/chat/services/chat-registry.pipe-submit.test.ts b/src/apps/chat/services/chat-registry.pipe-submit.test.ts
new file mode 100644
index 0000000..cff3d66
--- /dev/null
+++ b/src/apps/chat/services/chat-registry.pipe-submit.test.ts
@@ -0,0 +1,1338 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { globalPtys } from '../../shell/src/runtime/shell-state.js';
+import { setActiveProject } from '../../../project-context.js';
+
+const chatStoreMock = vi.hoisted(() => {
+ let seq = 0;
+ const messages: any[] = [];
+ const pipeEvents: any[] = [];
+ return {
+ appendMessage: vi.fn((msg: Record) => {
+ const stored = {
+ id: `msg-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ topic: null,
+ ...msg,
+ };
+ messages.push(stored);
+ return stored;
+ }),
+ appendPipeEvent: vi.fn((event: Record) => {
+ const stored = {
+ id: `pipe-event-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ ...event,
+ };
+ pipeEvents.push(stored);
+ return stored;
+ }),
+ readMessages: vi.fn(() => [...messages]),
+ clearMessages: vi.fn(() => {
+ messages.length = 0;
+ pipeEvents.length = 0;
+ }),
+ reset: () => {
+ seq = 0;
+ messages.length = 0;
+ pipeEvents.length = 0;
+ },
+ };
+});
+
+vi.mock('./chat-store.js', () => ({
+ appendMessage: chatStoreMock.appendMessage,
+ appendPipeEvent: chatStoreMock.appendPipeEvent,
+ readMessages: chatStoreMock.readMessages,
+ clearMessages: chatStoreMock.clearMessages,
+ saveParticipants: vi.fn(),
+ loadParticipants: vi.fn(() => []),
+}));
+
+const registry = await import('./chat-registry.js');
+
+describe('chat-registry store-backed pipe submissions', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ chatStoreMock.appendPipeEvent.mockClear();
+ chatStoreMock.readMessages.mockClear();
+ chatStoreMock.clearMessages.mockClear();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' });
+
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ setActiveProject(null);
+ });
+
+ it('advances merge-all from blind fan-out to final synthesis', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/merge-all-pipe @${alice.name} @${bob.name} review this`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const started = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(started?.mode).toBe('merge-all');
+ expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([
+ `${alice.name}:fan-out`,
+ `${bob.name}:fan-out`,
+ ]);
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice analysis', 'project-chat');
+ await vi.advanceTimersByTimeAsync(1_000);
+ const aliceResult = await aliceSubmit;
+ expect(aliceResult.ok).toBe(true);
+ expect(aliceResult.message?.pipe?.role).toBe('fan-out');
+ // Alice has no more slots — her work is complete
+ expect(aliceResult.myWorkComplete).toBe(true);
+ expect(aliceResult.pendingStages).toBe(0);
+
+ const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob blind analysis', 'project-chat');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const bobFanOutResult = await bobFanOutSubmit;
+ expect(bobFanOutResult.ok).toBe(true);
+ expect(bobFanOutResult.message?.pipe?.role).toBe('fan-out');
+ // Bob is the synthesizer (last assignee) — he still has the final slot pending
+ expect(bobFanOutResult.myWorkComplete).toBe(false);
+ expect(bobFanOutResult.pendingStages).toBe(1);
+
+ const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([
+ `${bob.name}:final`,
+ ]);
+
+ const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'merged final', 'project-chat');
+ // Final submit now broadcasts the result to all PTYs via the completion handler,
+ // which requires extra timer advancement (1000ms per participant for PTY delivery)
+ await vi.advanceTimersByTimeAsync(5_000);
+ const bobFinalResult = await bobFinalSubmit;
+ expect(bobFinalResult.ok).toBe(true);
+ expect(bobFinalResult.message?.pipe?.role).toBe('final');
+ // Bob's final submission — all work complete
+ expect(bobFinalResult.myWorkComplete).toBe(true);
+ expect(bobFinalResult.pendingStages).toBe(0);
+
+ const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(completed?.status).toBe('completed');
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+
+ it('defaults /explain to active attached LLMs and completes merge-all style orchestration', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-c', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+ const detached = registry.join('carol', 'llm', 'pane-c', 'carol', '\r');
+ registry.join('user-self', 'user', null, null, '\r');
+ registry.detach(detached.name);
+
+ const startPromise = registry.send('user', '/explain explain this failure');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const started = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(started?.mode).toBe('explain');
+ expect(started?.assignees).toEqual([alice.name, bob.name]);
+ expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([
+ `${alice.name}:fan-out`,
+ `${bob.name}:fan-out`,
+ ]);
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice explanation', 'project-chat');
+ await vi.advanceTimersByTimeAsync(1_000);
+ const aliceResult = await aliceSubmit;
+ expect(aliceResult.ok).toBe(true);
+ // Alice (non-synthesizer) has no more work after fan-out
+ expect(aliceResult.myWorkComplete).toBe(true);
+ expect(aliceResult.pendingStages).toBe(0);
+
+ const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob explanation', 'project-chat');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const bobFanOutResult = await bobFanOutSubmit;
+ expect(bobFanOutResult.ok).toBe(true);
+ // Bob (synthesizer) still has the final slot pending
+ expect(bobFanOutResult.myWorkComplete).toBe(false);
+ expect(bobFanOutResult.pendingStages).toBe(1);
+
+ const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([
+ `${bob.name}:final`,
+ ]);
+
+ const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final explanation', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ const bobFinalResult = await bobFinalSubmit;
+ expect(bobFinalResult.ok).toBe(true);
+ expect(bobFinalResult.message?.pipe?.role).toBe('final');
+ // Bob's final submission — all work complete
+ expect(bobFinalResult.myWorkComplete).toBe(true);
+ expect(bobFinalResult.pendingStages).toBe(0);
+
+ const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(completed?.status).toBe('completed');
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ registry.leave(detached.name);
+ registry.leave('user-self');
+ });
+
+ it('defaults /summarize to active attached LLMs and completes merge-all style orchestration', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-c', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+ const detached = registry.join('carol', 'llm', 'pane-c', 'carol', '\r');
+ registry.join('user-self', 'user', null, null, '\r');
+ registry.detach(detached.name);
+
+ const startPromise = registry.send('user', '/summarize summarize this long topic');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const started = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(started?.mode).toBe('summarize');
+ expect(started?.assignees).toEqual([alice.name, bob.name]);
+ expect(started?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`).sort()).toEqual([
+ `${alice.name}:fan-out`,
+ `${bob.name}:fan-out`,
+ ]);
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'alice summary', 'project-chat');
+ await vi.advanceTimersByTimeAsync(1_000);
+ const aliceResult = await aliceSubmit;
+ expect(aliceResult.ok).toBe(true);
+ expect(aliceResult.myWorkComplete).toBe(true);
+ expect(aliceResult.pendingStages).toBe(0);
+
+ const bobFanOutSubmit = registry.submitPipeStage(pipeId!, bob.name, 'bob summary', 'project-chat');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const bobFanOutResult = await bobFanOutSubmit;
+ expect(bobFanOutResult.ok).toBe(true);
+ expect(bobFanOutResult.myWorkComplete).toBe(false);
+ expect(bobFanOutResult.pendingStages).toBe(1);
+
+ const afterFanOut = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(afterFanOut?.leases.map(lease => `${lease.assignee}:${lease.slotRole}`)).toEqual([
+ `${bob.name}:final`,
+ ]);
+
+ const bobFinalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final summary', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ const bobFinalResult = await bobFinalSubmit;
+ expect(bobFinalResult.ok).toBe(true);
+ expect(bobFinalResult.message?.pipe?.role).toBe('final');
+ expect(bobFinalResult.myWorkComplete).toBe(true);
+ expect(bobFinalResult.pendingStages).toBe(0);
+
+ const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(completed?.status).toBe('completed');
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ registry.leave(detached.name);
+ registry.leave('user-self');
+ });
+
+ it('returns myWorkComplete for linear pipe stages', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`);
+ await vi.advanceTimersByTimeAsync(3_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const started = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(started?.mode).toBe('linear');
+
+ // Alice is stage 1 — she has exactly one slot, so after submission she's done
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(3_000);
+ const aliceResult = await aliceSubmit;
+ expect(aliceResult.ok).toBe(true);
+ expect(aliceResult.myWorkComplete).toBe(true);
+ expect(aliceResult.pendingStages).toBe(0);
+
+ // Bob is stage 2 (final) — he also has exactly one slot
+ const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ const bobResult = await bobSubmit;
+ expect(bobResult.ok).toBe(true);
+ expect(bobResult.message?.pipe?.role).toBe('final');
+ expect(bobResult.myWorkComplete).toBe(true);
+ expect(bobResult.pendingStages).toBe(0);
+
+ const completed = registry.getPipeStoreStatus(pipeId!, 'project-chat');
+ expect(completed?.status).toBe('completed');
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+
+ it('does not emit a private pipe-step event for the final stage submission', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`);
+ await vi.advanceTimersByTimeAsync(3_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(3_000);
+ await aliceSubmit;
+
+ chatStoreMock.appendPipeEvent.mockClear();
+
+ const finalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ await finalSubmit;
+
+ const stageOutputCalls = chatStoreMock.appendPipeEvent.mock.calls
+ .map(([event]) => event)
+ .filter((event: any) => event.type === 'stage-output');
+ expect(stageOutputCalls).toEqual([]);
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+
+ it('preserves the pipe anchor on the public final chat message', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} analyze this`);
+ await vi.advanceTimersByTimeAsync(3_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(3_000);
+ await aliceSubmit;
+
+ chatStoreMock.appendMessage.mockClear();
+
+ const finalSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ await finalSubmit;
+
+ const finalMessage = chatStoreMock.appendMessage.mock.calls
+ .map(([message]) => message)
+ .find((message: any) => message?.pipe?.role === 'final');
+
+ expect(finalMessage).toBeDefined();
+ expect(finalMessage!.body).toBe(`#pipe-${pipeId} final output`);
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+
+ it('final output is NOT PTY-delivered to LLM participants (user-only delivery)', async () => {
+ const writesA: string[] = [];
+ const writesB: string[] = [];
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn((c: string) => { writesA.push(c); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn((c: string) => { writesB.push(c); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Clear writes from pipe setup (handoff notifications)
+ writesA.length = 0;
+ writesB.length = 0;
+
+ // Alice submits stage 1
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(3_000);
+ await aliceSubmit;
+
+ // Clear writes from stage 1 handoff to bob
+ writesA.length = 0;
+ writesB.length = 0;
+
+ // Bob submits final stage
+ const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output content', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ await bobSubmit;
+
+ // Neither LLM should have received the final output via PTY
+ const allWrites = [...writesA, ...writesB];
+ const finalDeliveries = allWrites.filter(w => w.includes('final output content'));
+ expect(finalDeliveries).toEqual([]);
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+
+ it('final output message is persisted with to="user" (not broadcast)', async () => {
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ const alice = registry.join('alice', 'llm', 'pane-a', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'pane-b', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do work`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-chat')[0]?.pipeId;
+
+ const aliceSubmit = registry.submitPipeStage(pipeId!, alice.name, 'stage 1 output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(3_000);
+ await aliceSubmit;
+
+ chatStoreMock.appendMessage.mockClear();
+
+ const bobSubmit = registry.submitPipeStage(pipeId!, bob.name, 'final output', 'project-chat');
+ await vi.advanceTimersByTimeAsync(5_000);
+ await bobSubmit;
+
+ const finalMessage = chatStoreMock.appendMessage.mock.calls
+ .map(([message]) => message)
+ .find((message: any) => message?.pipe?.role === 'final');
+
+ expect(finalMessage).toBeDefined();
+ expect(finalMessage!.to).toBe('user');
+
+ registry.leave(alice.name);
+ registry.leave(bob.name);
+ });
+});
+
+describe('readPipeOutput entitlement', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ chatStoreMock.appendPipeEvent.mockClear();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-read', name: 'Read', path: '/tmp/read' });
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ setActiveProject(null);
+ });
+
+ function addPanes(...ids: string[]) {
+ for (const id of ids) {
+ globalPtys.set(id, { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ }
+ }
+
+ it('returns 404 for unknown pipe', () => {
+ const result = registry.readPipeOutput('nonexistent', 'alice', 'project-read');
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(404);
+ });
+
+ it('returns 403 for non-assignee', async () => {
+ addPanes('p1', 'p2', 'p3');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+ registry.join('carol', 'llm', 'p3', 'carol', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const result = registry.readPipeOutput(pipeId!, 'carol', 'project-read');
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(403);
+ });
+
+ it('returns prompt payload for stage-1 caller', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const result = registry.readPipeOutput(pipeId!, alice.name, 'project-read');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.stagePayload).toContain('Prompt: do something');
+ expect(result.data.previousOutput).toBeNull();
+ }
+ });
+
+ it('returns previous stage output for linear downstream assignee', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Bob can't read yet — handoff for stage 2 not emitted
+ const premature = registry.readPipeOutput(pipeId!, bob.name, 'project-read');
+ expect(premature.ok).toBe(false);
+ if (!premature.ok) expect(premature.status).toBe(409);
+
+ // Alice submits stage 1 → triggers handoff to bob (stage 2)
+ const submitPromise = registry.submitPipeStage(pipeId!, alice.name, 'alice output', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await submitPromise;
+
+ // Now bob can read alice's output
+ const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.previousOutput?.stage).toBe(1);
+ expect(result.data.previousOutput?.from).toBe(alice.name);
+ expect(result.data.previousOutput?.content).toBe('alice output');
+ }
+ });
+
+ it('returns 409 for completed pipe', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Submit both stages to complete the pipe
+ const s1 = registry.submitPipeStage(pipeId!, alice.name, 'alice output', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+
+ const s2 = registry.submitPipeStage(pipeId!, bob.name, 'bob output', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+
+ // Pipe is now completed — reads should fail with 409
+ const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read');
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.status).toBe(409);
+ });
+
+ it('returns fan-out outputs for synthesizer in merge mode', async () => {
+ addPanes('p1', 'p2', 'p3');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+ const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r');
+
+ const startPromise = registry.send('user', `/merge-pipe @${alice.name} @${bob.name} @${carol.name} review this`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Carol (synthesizer) can't read yet — synth not requested
+ const premature = registry.readPipeOutput(pipeId!, carol.name, 'project-read');
+ expect(premature.ok).toBe(false);
+ if (!premature.ok) expect(premature.status).toBe(409);
+
+ // Alice (fan-out) can read her stage payload before submitting
+ const fanOutPrompt = registry.readPipeOutput(pipeId!, alice.name, 'project-read');
+ expect(fanOutPrompt.ok).toBe(true);
+ if (fanOutPrompt.ok) {
+ expect(fanOutPrompt.data.stagePayload).toContain('review this');
+ expect(fanOutPrompt.data.fanOutOutputs).toBeUndefined();
+ }
+
+ // Submit fan-out outputs
+ const s1 = registry.submitPipeStage(pipeId!, alice.name, 'alice analysis', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+
+ const s2 = registry.submitPipeStage(pipeId!, bob.name, 'bob analysis', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+
+ // Now carol can read fan-out outputs
+ const result = registry.readPipeOutput(pipeId!, carol.name, 'project-read');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.fanOutOutputs).toHaveLength(2);
+ const fromNames = result.data.fanOutOutputs!.map(o => o.from).sort();
+ expect(fromNames).toEqual([alice.name, bob.name]);
+ }
+ });
+
+ it('returns fan-out prompt payload for non-synth participant in merge mode', async () => {
+ addPanes('p1', 'p2', 'p3');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+ const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r');
+
+ const startPromise = registry.send('user', `/merge-pipe @${alice.name} @${bob.name} @${carol.name} review this`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const result = registry.readPipeOutput(pipeId!, alice.name, 'project-read');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.stagePayload).toContain('review this');
+ expect(result.data.previousOutput).toBeNull();
+ expect(result.data.fanOutOutputs).toBeUndefined();
+ }
+ });
+
+ it('returns prompt payload for merge-all synthesizer during fan-out phase', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/explain @${alice.name} @${bob.name} explain this failure`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ const result = registry.readPipeOutput(pipeId!, bob.name, 'project-read');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.stagePayload).toContain('explain this failure');
+ expect(result.data.previousOutput).toBeNull();
+ expect(result.data.fanOutOutputs).toBeUndefined();
+ }
+ });
+
+ it('cross-stage isolation: stage-3 cannot read stage-1 output (only stage-2)', async () => {
+ addPanes('p1', 'p2', 'p3');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+ const carol = registry.join('carol', 'llm', 'p3', 'carol', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} @${carol.name} chain work`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Alice submits stage 1
+ const s1 = registry.submitPipeStage(pipeId!, alice.name, 'stage-1 output', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+
+ // Bob can read stage 1 (previous to stage 2)
+ const bobRead = registry.readPipeOutput(pipeId!, bob.name, 'project-read');
+ expect(bobRead.ok).toBe(true);
+ if (bobRead.ok) {
+ expect(bobRead.data.previousOutput?.stage).toBe(1);
+ expect(bobRead.data.previousOutput?.content).toBe('stage-1 output');
+ }
+
+ // Carol cannot read yet — handoff for stage 3 not emitted
+ const carolPremature = registry.readPipeOutput(pipeId!, carol.name, 'project-read');
+ expect(carolPremature.ok).toBe(false);
+ if (!carolPremature.ok) expect(carolPremature.status).toBe(409);
+
+ // Bob submits stage 2
+ const s2 = registry.submitPipeStage(pipeId!, bob.name, 'stage-2 output', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+
+ // Now carol can read stage 2 (not stage 1)
+ const carolRead = registry.readPipeOutput(pipeId!, carol.name, 'project-read');
+ expect(carolRead.ok).toBe(true);
+ if (carolRead.ok) {
+ expect(carolRead.data.previousOutput?.stage).toBe(2);
+ expect(carolRead.data.previousOutput?.content).toBe('stage-2 output');
+ }
+ });
+
+ it('handoff prompt does not contain inline output markers', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const startPromise = registry.send('user', `/linear-pipe @${alice.name} @${bob.name} do something`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const pipeId = registry.getActivePipes('project-read')[0]?.pipeId;
+ expect(pipeId).toBeDefined();
+
+ // Alice submits stage 1 → triggers handoff to bob
+ const submitPromise = registry.submitPipeStage(pipeId!, alice.name, 'big output here', 'project-read');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await submitPromise;
+
+ // Handoff is delivered via PTY to bob's pane (p2)
+ const bobPty = globalPtys.get('p2') as { ptyProcess: { write: ReturnType } } | undefined;
+ expect(bobPty).toBeDefined();
+ const writeCall = bobPty!.ptyProcess.write.mock.calls.find(
+ (args: unknown[]) => typeof args[0] === 'string' && args[0].includes(`#pipe-${pipeId}`) && args[0].includes('stage 2'),
+ );
+ expect(writeCall).toBeDefined();
+ const handoffText = writeCall![0] as string;
+ // Must NOT contain inline output
+ expect(handoffText).not.toContain('--- Previous stage output ---');
+ expect(handoffText).not.toContain('big output here');
+ // Must contain pipe_read_output instruction
+ expect(handoffText).toContain('pipe_read_output(pipeId=');
+ });
+});
+
+const brainstormStore = await import('./brainstorm-store.js');
+
+describe('brainstorm command handling', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ brainstormStore._resetForTest();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-bs', name: 'BS', path: '/tmp/bs' });
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ setActiveProject(null);
+ });
+
+ function addPanes(...ids: string[]) {
+ for (const id of ids) {
+ globalPtys.set(id, { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ }
+ }
+
+ it('creates a brainstorm record and launches child pipe', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a caching layer`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const brainstorms = registry.getActiveBrainstorms('project-bs');
+ expect(brainstorms).toHaveLength(1);
+ expect(brainstorms[0].prompt).toBe('design a caching layer');
+ expect(brainstorms[0].assignees).toEqual([alice.name, bob.name]);
+ expect(brainstorms[0].phase).toBe('ideas');
+ expect(brainstorms[0].activeChildPipeId).toBeDefined();
+ expect(brainstorms[0].ideaIterations).toBe(1);
+ });
+
+ it('defaults to all active LLMs when no assignees specified', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm design a caching layer`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const brainstorms = registry.getActiveBrainstorms('project-bs');
+ expect(brainstorms).toHaveLength(1);
+ expect(brainstorms[0].assignees.sort()).toEqual([alice.name, bob.name].sort());
+ });
+
+ it('emits a start message with brainstorm anchor', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const brainstorms = registry.getActiveBrainstorms('project-bs');
+ const startMsg = chatStoreMock.appendMessage.mock.calls
+ .map(([m]: [any]) => m)
+ .find((m: any) => typeof m?.body === 'string' && m.body.includes('#brainstorm-'));
+ expect(startMsg).toBeDefined();
+ expect(startMsg.body).toContain(`#brainstorm-${brainstorms[0].id}`);
+ expect(startMsg.body).toContain('Phase: Ideas');
+ });
+
+ it('rejects with error when fewer than 2 LLMs available', async () => {
+ addPanes('p1');
+ registry.join('alice', 'llm', 'p1', 'alice', '\r');
+
+ await registry.send('user', `/brainstorm design a cache`);
+
+ const errorMsg = chatStoreMock.appendMessage.mock.calls
+ .map(([m]: [any]) => m)
+ .find((m: any) => typeof m?.body === 'string' && m.body.includes('Brainstorm error'));
+ expect(errorMsg).toBeDefined();
+ expect(errorMsg.body).toContain('at least 2');
+ });
+
+ it('rejects when first leading @name is unknown', async () => {
+ addPanes('p1', 'p2');
+ registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ await registry.send('user', `/brainstorm @ghost design a cache`);
+
+ const errorMsg = chatStoreMock.appendMessage.mock.calls
+ .map(([m]: [any]) => m)
+ .find((m: any) => typeof m?.body === 'string' && m.body.includes('Brainstorm error'));
+ expect(errorMsg).toBeDefined();
+ expect(errorMsg.body).toContain('@ghost');
+ });
+
+ it('transitions to ideas_review with candidateIdea when child pipe completes', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ const childPipeId = bs.activeChildPipeId!;
+ expect(childPipeId).toBeDefined();
+
+ // Submit fan-out outputs from both LLMs, then bob submits final synthesis
+ const s1 = registry.submitPipeStage(childPipeId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+
+ const s2 = registry.submitPipeStage(childPipeId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+
+ // Bob is the synthesizer in merge-all — submits the final output
+ const s3 = registry.submitPipeStage(childPipeId, bob.name, 'synthesized idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s3;
+
+ // Brainstorm should now be in ideas_review with candidateIdea set
+ const updated = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(updated?.phase).toBe('ideas_review');
+ expect(updated?.candidateIdea).toBeTruthy();
+ expect(updated?.acceptedIdea).toBeNull();
+ expect(updated?.activeChildPipeId).toBeNull();
+ });
+
+ it('retry re-launches a new child pipe with user note', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ const firstChildId = bs.activeChildPipeId!;
+
+ // Complete the first idea round (fan-out + synthesis)
+ const s1 = registry.submitPipeStage(firstChildId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+ const s2 = registry.submitPipeStage(firstChildId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+ const s3 = registry.submitPipeStage(firstChildId, bob.name, 'synthesized idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s3;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review');
+
+ // Retry with a note
+ const retryPromise = registry.brainstormRetryIdeas(bs.id, 'focus on Redis', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const retried = await retryPromise;
+ expect(retried).toBe(true);
+
+ const afterRetry = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterRetry?.phase).toBe('ideas');
+ expect(afterRetry?.activeChildPipeId).not.toBe(firstChildId);
+ expect(afterRetry?.activeChildPipeId).toBeTruthy();
+ expect(afterRetry?.ideaIterations).toBe(2);
+ expect(afterRetry?.latestUserNote).toBe('focus on Redis');
+ });
+
+ it('accept promotes candidateIdea to acceptedIdea and advances to details', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ const childPipeId = bs.activeChildPipeId!;
+
+ // Complete the idea round (fan-out + synthesis)
+ const s1 = registry.submitPipeStage(childPipeId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+ const s2 = registry.submitPipeStage(childPipeId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+ const s3 = registry.submitPipeStage(childPipeId, bob.name, 'synthesized idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s3;
+
+ const preAccept = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(preAccept?.phase).toBe('ideas_review');
+ expect(preAccept?.candidateIdea).toBeTruthy();
+ const candidateBeforeAccept = preAccept!.candidateIdea;
+
+ // Accept the idea (launches detail pipe)
+ const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const accepted = await acceptPromise;
+ expect(accepted).toBe(true);
+
+ const afterAccept = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterAccept?.phase).toBe('details');
+ expect(afterAccept?.acceptedIdea).toBe(candidateBeforeAccept);
+ expect(afterAccept?.candidateIdea).toBeNull();
+ });
+
+ it('reject retry when not in ideas_review phase', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ // Still in 'ideas' phase (pipe running), retry should fail
+ const retried = await registry.brainstormRetryIdeas(bs.id, null, 'project-bs');
+ expect(retried).toBe(false);
+ });
+
+ /** Helper: run a brainstorm through idea acceptance, returning the record. */
+ async function runThroughIdeaAcceptance(alice: any, bob: any) {
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ const childId = bs.activeChildPipeId!;
+
+ // Complete merge-all idea round (fan-out + synthesis)
+ const s1 = registry.submitPipeStage(childId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+ const s2 = registry.submitPipeStage(childId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+ const s3 = registry.submitPipeStage(childId, bob.name, 'synthesized idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s3;
+
+ // Accept the idea → launches detail pipe
+ const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await acceptPromise;
+
+ return bs;
+ }
+
+ it('accept idea launches linear detail pipe', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const bs = await runThroughIdeaAcceptance(alice, bob);
+ const record = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(record?.phase).toBe('details');
+ expect(record?.acceptedIdea).toBeTruthy();
+ expect(record?.activeChildPipeId).toBeTruthy();
+ expect(record?.detailIterations).toBe(1);
+ });
+
+ it('detail pipe completion transitions to details_review with candidateDraft', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const bs = await runThroughIdeaAcceptance(alice, bob);
+ const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!;
+
+ // Complete the linear detail pipe (alice stage 1 → bob stage 2 final)
+ const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d1;
+ const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d2;
+
+ const record = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(record?.phase).toBe('details_review');
+ expect(record?.candidateDraft).toBeTruthy();
+ expect(record?.activeChildPipeId).toBeNull();
+ });
+
+ it('finalize accepts draft and launches final pass, then completes brainstorm', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const bs = await runThroughIdeaAcceptance(alice, bob);
+ const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!;
+
+ // Complete detail pipe
+ const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d1;
+ const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d2;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review');
+
+ // Finalize → launches final pass
+ const finalizePromise = registry.brainstormFinalize(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await finalizePromise;
+
+ const afterFinalize = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterFinalize?.phase).toBe('finalizing');
+ expect(afterFinalize?.acceptedDraft).toBeTruthy();
+
+ // Complete the final pass (single assignee: alice)
+ const finalPipeId = afterFinalize!.activeChildPipeId!;
+ const f1 = registry.submitPipeStage(finalPipeId, alice.name, 'final comprehensive document', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await f1;
+
+ const completed = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(completed?.phase).toBe('complete');
+ });
+
+ it('back to ideas returns to ideas_review from details_review', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const bs = await runThroughIdeaAcceptance(alice, bob);
+ const detailPipeId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!;
+
+ // Complete detail pipe
+ const d1 = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d1;
+ const d2 = registry.submitPipeStage(detailPipeId, bob.name, 'bob final details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d2;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review');
+
+ const backed = await registry.brainstormBackToIdeas(bs.id, 'project-bs');
+ expect(backed).toBe(true);
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review');
+ });
+
+ it('adjust relaunches detail pass with user note', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const bs = await runThroughIdeaAcceptance(alice, bob);
+ const firstDetailId = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!;
+
+ // Complete detail pipe
+ const d1 = registry.submitPipeStage(firstDetailId, alice.name, 'alice details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d1;
+ const d2 = registry.submitPipeStage(firstDetailId, bob.name, 'bob final details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await d2;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review');
+
+ // Adjust with a note
+ const adjustPromise = registry.brainstormAdjustDetails(bs.id, 'add error handling section', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ const adjusted = await adjustPromise;
+ expect(adjusted).toBe(true);
+
+ const afterAdjust = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterAdjust?.phase).toBe('details');
+ expect(afterAdjust?.activeChildPipeId).not.toBe(firstDetailId);
+ expect(afterAdjust?.activeChildPipeId).toBeTruthy();
+ expect(afterAdjust?.detailIterations).toBe(2);
+ expect(afterAdjust?.latestUserNote).toBe('add error handling section');
+ });
+
+ it('idea acceptance clears latestUserNote so it does not leak into detail phase', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ // Start brainstorm and complete idea round
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ const childId = bs.activeChildPipeId!;
+
+ const s1 = registry.submitPipeStage(childId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s1;
+ const s2 = registry.submitPipeStage(childId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s2;
+ const s3 = registry.submitPipeStage(childId, bob.name, 'synthesized idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await s3;
+
+ // Retry with a note
+ const retryPromise = registry.brainstormRetryIdeas(bs.id, 'focus on Redis', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await retryPromise;
+
+ // Complete second idea round
+ const childId2 = registry.getBrainstormRecord(bs.id, 'project-bs')!.activeChildPipeId!;
+ const r1 = registry.submitPipeStage(childId2, alice.name, 'alice redis idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await r1;
+ const r2 = registry.submitPipeStage(childId2, bob.name, 'bob redis idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await r2;
+ const r3 = registry.submitPipeStage(childId2, bob.name, 'synthesized redis idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await r3;
+
+ // Note should still be set before acceptance
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.latestUserNote).toBe('focus on Redis');
+
+ // Accept idea → should clear the note
+ const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await acceptPromise;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.latestUserNote).toBeNull();
+ });
+
+ it('brainstorm does not add a new PipeMode — child pipes use existing modes', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ const sendPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await sendPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ // The child pipe should be a standard merge-all pipe, not a new "brainstorm" mode
+ const childPipeStatus = registry.getPipeStoreStatus(bs.activeChildPipeId!, 'project-bs');
+ expect(childPipeStatus?.mode).toBe('merge-all');
+ });
+
+ it('full end-to-end brainstorm flow: start → ideas → accept → details → finalize → complete', async () => {
+ addPanes('p1', 'p2');
+ const alice = registry.join('alice', 'llm', 'p1', 'alice', '\r');
+ const bob = registry.join('bob', 'llm', 'p2', 'bob', '\r');
+
+ // Phase 1: Start brainstorm → merge-all idea round
+ const startPromise = registry.send('user', `/brainstorm @${alice.name} @${bob.name} : design a cache`);
+ await vi.advanceTimersByTimeAsync(2_000);
+ await startPromise;
+
+ const bs = registry.getActiveBrainstorms('project-bs')[0];
+ expect(bs.phase).toBe('ideas');
+
+ // Complete merge-all: fan-out + synthesis
+ const ideaPipeId = bs.activeChildPipeId!;
+ let sub = registry.submitPipeStage(ideaPipeId, alice.name, 'alice idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+ sub = registry.submitPipeStage(ideaPipeId, bob.name, 'bob idea', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+ sub = registry.submitPipeStage(ideaPipeId, bob.name, 'merged idea summary', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('ideas_review');
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.candidateIdea).toBeTruthy();
+
+ // Phase 2: Accept idea → linear detail round
+ const acceptPromise = registry.brainstormAcceptIdea(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await acceptPromise;
+
+ const afterAccept = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterAccept?.phase).toBe('details');
+ expect(afterAccept?.acceptedIdea).toBeTruthy();
+ const detailPipeId = afterAccept!.activeChildPipeId!;
+
+ // Verify child pipe is a standard linear mode
+ expect(registry.getPipeStoreStatus(detailPipeId, 'project-bs')?.mode).toBe('linear');
+
+ // Complete linear: alice stage 1 → bob stage 2 (final)
+ sub = registry.submitPipeStage(detailPipeId, alice.name, 'alice details', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+ sub = registry.submitPipeStage(detailPipeId, bob.name, 'detailed draft', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('details_review');
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.candidateDraft).toBeTruthy();
+
+ // Phase 3: Finalize → single assignee final pass
+ const finalizePromise = registry.brainstormFinalize(bs.id, 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000);
+ await finalizePromise;
+
+ const afterFinalize = registry.getBrainstormRecord(bs.id, 'project-bs');
+ expect(afterFinalize?.phase).toBe('finalizing');
+ expect(afterFinalize?.acceptedDraft).toBeTruthy();
+ const finalPipeId = afterFinalize!.activeChildPipeId!;
+
+ // Complete final pass
+ sub = registry.submitPipeStage(finalPipeId, alice.name, 'final comprehensive document', 'project-bs');
+ await vi.advanceTimersByTimeAsync(2_000); await sub;
+
+ expect(registry.getBrainstormRecord(bs.id, 'project-bs')?.phase).toBe('complete');
+ // Brainstorm should no longer appear in active list
+ expect(registry.getActiveBrainstorms('project-bs')).toHaveLength(0);
+ });
+});
diff --git a/src/apps/chat/services/chat-registry.targeted-delivery.test.ts b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts
new file mode 100644
index 0000000..4162b27
--- /dev/null
+++ b/src/apps/chat/services/chat-registry.targeted-delivery.test.ts
@@ -0,0 +1,663 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { globalPtys } from '../../shell/src/runtime/shell-state.js';
+import { setActiveProject } from '../../../project-context.js';
+
+const chatStoreMock = vi.hoisted(() => {
+ let seq = 0;
+ return {
+ appendMessage: vi.fn((msg: { from: string; to: string | null; body: string; type: string }) => ({
+ id: `msg-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ topic: null,
+ ...msg,
+ })),
+ appendPipeEvent: vi.fn((event: Record) => ({
+ id: `pipe-event-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ ...event,
+ })),
+ clearMessages: vi.fn(),
+ readMessages: vi.fn(() => []),
+ reset: () => { seq = 0; },
+ };
+});
+
+vi.mock('./chat-store.js', () => ({
+ appendMessage: chatStoreMock.appendMessage,
+ appendPipeEvent: chatStoreMock.appendPipeEvent,
+ clearMessages: chatStoreMock.clearMessages,
+ readMessages: chatStoreMock.readMessages,
+ saveParticipants: vi.fn(),
+ loadParticipants: vi.fn(() => []),
+}));
+
+const registry = await import('./chat-registry.js');
+
+function pty(from: string, body: string): string {
+ return `[DevGlide Chat] @${from}: ${body}`;
+}
+
+async function flushDeliveryQueue(): Promise {
+ await vi.advanceTimersByTimeAsync(0);
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// UNIT TESTS — parseTargetTokens (pure function, no state)
+// ═══════════════════════════════════════════════════════════════════
+
+describe('parseTargetTokens', () => {
+ it('extracts a single @mention', () => {
+ expect(registry.parseTargetTokens('@claude-7 do X')).toEqual(['claude-7']);
+ });
+
+ it('extracts @all', () => {
+ expect(registry.parseTargetTokens('@all heads up')).toEqual(['all']);
+ });
+
+ it('extracts multiple @mentions', () => {
+ expect(registry.parseTargetTokens('@claude-7 @codex-14 review this')).toEqual(['claude-7', 'codex-14']);
+ });
+
+ it('returns empty for no mentions', () => {
+ expect(registry.parseTargetTokens('no mentions here')).toEqual([]);
+ });
+
+ it('extracts @user as a token', () => {
+ expect(registry.parseTargetTokens('@user done')).toEqual(['user']);
+ });
+
+ it('extracts @team-prefixed tokens', () => {
+ expect(registry.parseTargetTokens('@team-ui-squad go')).toEqual(['team-ui-squad']);
+ });
+
+ it('strips trailing punctuation from tokens', () => {
+ expect(registry.parseTargetTokens('@claude-7, @codex-14: check')).toEqual(['claude-7', 'codex-14']);
+ });
+
+ it('deduplicates repeated mentions', () => {
+ expect(registry.parseTargetTokens('@claude-7 and @claude-7 again')).toEqual(['claude-7']);
+ });
+
+ it('merges explicit to param for user senders', () => {
+ expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'user')).toEqual(['claude-7', 'codex-14']);
+ });
+
+ it('merges explicit to param for LLM senders (issue 2 fix)', () => {
+ expect(registry.parseTargetTokens('@codex-14 review', 'claude-7', 'llm')).toEqual(['claude-7', 'codex-14']);
+ });
+
+ it('deduplicates to param when also in body', () => {
+ expect(registry.parseTargetTokens('@claude-7 check', 'claude-7', 'user')).toEqual(['claude-7']);
+ });
+
+ // ── Code-aware mention extraction (self-loop bug fix) ─────────────
+ // Mentions inside inline code spans (`...`) and fenced code blocks (```...```)
+ // are example syntax, not actual addressees. The parser must skip them.
+
+ it('ignores @mention inside inline code span', () => {
+ expect(registry.parseTargetTokens('use `@claude-7 fix` to assign')).toEqual([]);
+ });
+
+ it('ignores @mention inside fenced code block', () => {
+ const body = 'example:\n```\n@claude-7 do this\n```\nplease';
+ expect(registry.parseTargetTokens(body)).toEqual([]);
+ });
+
+ it('ignores @mention inside fenced code block with language tag', () => {
+ const body = '```ts\nconst x = "@claude-7";\n```';
+ expect(registry.parseTargetTokens(body)).toEqual([]);
+ });
+
+ it('still captures real prose mentions when code blocks exist', () => {
+ const body = '@codex-14 see example: `@claude-7 fix` — got it?';
+ expect(registry.parseTargetTokens(body)).toEqual(['codex-14']);
+ });
+
+ it('does not capture regex literal characters as a mention token', () => {
+ // Real-world: claude-3 explained the bug using `/@(\\S+)/g` in a code block
+ // and the parser captured "(\S+)/g" as a recipient.
+ const body = 'the parser uses `/@(\\S+)/g` to scan';
+ expect(registry.parseTargetTokens(body)).toEqual([]);
+ });
+
+ it('handles mentions split across prose and code without leaking', () => {
+ const body = '@codex-14 here is the bug:\n```\n@self-loop here\n```\nfix it';
+ expect(registry.parseTargetTokens(body)).toEqual(['codex-14']);
+ });
+
+ // ── Markdown-immune mention parsing (recipient-garbage bug) ─────────
+ // Real-world: a chat message containing markdown bold around a mention
+ // like `**Coordination, @codex-3:**` previously captured `codex-3:**`
+ // as a token because /@(\S+)/g is too greedy and the trailing-punct
+ // strip only handled `[,.:;!?]+`.
+
+ it('does not capture trailing markdown-bold marker as part of mention', () => {
+ expect(registry.parseTargetTokens('**Coordination, @codex-3:**')).toEqual(['codex-3']);
+ });
+
+ it('does not capture leading markdown bold as part of mention', () => {
+ expect(registry.parseTargetTokens('**@user @codex-3** review')).toEqual(['user', 'codex-3']);
+ });
+
+ it('does not capture trailing underscore emphasis as part of mention', () => {
+ expect(registry.parseTargetTokens('emphasised _@claude-7_ here')).toEqual(['claude-7']);
+ });
+
+ it('does not capture trailing parenthesis as part of mention', () => {
+ expect(registry.parseTargetTokens('(see @codex-14) for context')).toEqual(['codex-14']);
+ });
+
+ it('does not capture trailing tilde or asterisk decoration', () => {
+ expect(registry.parseTargetTokens('~@claude-7~ *@codex-14*')).toEqual(['claude-7', 'codex-14']);
+ });
+
+ // ── Comma-separated `to` param (parser stored it as one literal) ────
+ // Real-world: an MCP caller passed `to: "codex-3,pi-1"` and the parser
+ // stored that whole string as a single token, which then leaked into
+ // both the unresolved targets AND the displayed `msg.to` header.
+
+ it('splits comma-separated to param into separate tokens', () => {
+ expect(registry.parseTargetTokens('hello', 'codex-7,pi-1', 'llm')).toEqual(['codex-7', 'pi-1']);
+ });
+
+ it('splits comma+space-separated to param', () => {
+ expect(registry.parseTargetTokens('hello', 'codex-7, pi-1', 'llm')).toEqual(['codex-7', 'pi-1']);
+ });
+
+ it('dedupes comma-separated to param against body @mentions', () => {
+ expect(registry.parseTargetTokens('@codex-7 review', 'codex-7,pi-1', 'llm'))
+ .toEqual(['codex-7', 'pi-1']);
+ });
+
+ it('drops empty entries from comma-separated to param', () => {
+ expect(registry.parseTargetTokens('hi', 'codex-7,,pi-1,', 'llm')).toEqual(['codex-7', 'pi-1']);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════
+// UNIT TESTS — expandToRecipients (state-dependent)
+// ═══════════════════════════════════════════════════════════════════
+
+describe('expandToRecipients', () => {
+ let p1: { name: string };
+ let p2: { name: string };
+ let p3: { name: string };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' });
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+
+ globalPtys.set('pane-7', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ globalPtys.set('pane-14', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ globalPtys.set('pane-15', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ p1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r');
+ p2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r');
+ p3 = registry.join('cursor', 'llm', 'pane-15', 'cursor', '\r');
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ setActiveProject(null);
+ });
+
+ it('expands @all to all participants except sender', () => {
+ const result = registry.expandToRecipients(['all'], p1.name, 'project-test');
+ expect(result.recipients.sort()).toEqual([p2.name, p3.name].sort());
+ expect(result.concreteAssignees).toEqual([]);
+ });
+
+ it('resolves a known participant', () => {
+ const result = registry.expandToRecipients([p2.name], p1.name, 'project-test');
+ expect(result.recipients).toEqual([p2.name]);
+ expect(result.concreteAssignees).toEqual([p2.name]);
+ });
+
+ it('returns empty for unknown participant', () => {
+ const result = registry.expandToRecipients(['nonexistent'], p1.name, 'project-test');
+ expect(result.recipients).toEqual([]);
+ expect(result.concreteAssignees).toEqual([]);
+ });
+
+ it('returns empty recipients for semantic-only targets (user, system)', () => {
+ const result = registry.expandToRecipients(['user'], p1.name, 'project-test');
+ expect(result.recipients).toEqual([]);
+ expect(result.concreteAssignees).toEqual([]);
+ });
+
+ it('deduplicates @all + individual mention', () => {
+ const result = registry.expandToRecipients(['all', p2.name], p1.name, 'project-test');
+ expect(result.recipients.sort()).toEqual([p2.name, p3.name].sort());
+ expect(result.concreteAssignees).toEqual([p2.name]);
+ });
+
+ it('excludes detached participants from recipients but keeps in concreteAssignees', () => {
+ registry.detach(p2.name);
+ const result = registry.expandToRecipients([p2.name], p1.name, 'project-test');
+ expect(result.recipients).toEqual([]);
+ expect(result.concreteAssignees).toEqual([p2.name]);
+ });
+
+ it('excludes self from recipients', () => {
+ const result = registry.expandToRecipients([p1.name], p1.name, 'project-test');
+ expect(result.recipients).toEqual([]);
+ expect(result.concreteAssignees).toEqual([]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════
+// UNIT TESTS — buildDeliveryPlan
+// ═══════════════════════════════════════════════════════════════════
+
+describe('buildDeliveryPlan', () => {
+ let agent1: { name: string };
+ let agent2: { name: string };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' });
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+
+ globalPtys.set('pane-7', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ globalPtys.set('pane-14', { ptyProcess: { write: vi.fn() } as never, chunks: [], totalLen: 0 });
+ agent1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r');
+ agent2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r');
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ setActiveProject(null);
+ });
+
+ it('user with no mentions: fallbackBroadcast=true', () => {
+ const plan = registry.buildDeliveryPlan('user', 'hello everyone', undefined, 'user', 'project-test');
+ expect(plan.targetLabels).toEqual([]);
+ expect(plan.recipients).toEqual([]);
+ expect(plan.fallbackBroadcast).toBe(true);
+ });
+
+ it('user with @specific: targeted, no fallback', () => {
+ const plan = registry.buildDeliveryPlan('user', `@${agent1.name} implement this`, undefined, 'user', 'project-test');
+ expect(plan.targetLabels).toEqual([agent1.name]);
+ expect(plan.recipients).toEqual([agent1.name]);
+ expect(plan.concreteAssignees).toEqual([agent1.name]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ it('LLM with no mentions: no fallback, no recipients', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, 'thinking out loud', undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([]);
+ expect(plan.recipients).toEqual([]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ it('LLM with @specific: targeted delivery', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, `@${agent2.name} review this`, undefined, 'llm', 'project-test');
+ expect(plan.recipients).toEqual([agent2.name]);
+ expect(plan.concreteAssignees).toEqual([agent2.name]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ it('user with @unknown: no fallback (issue 1 fix — had target intent)', () => {
+ const plan = registry.buildDeliveryPlan('user', '@nonexistent check this', undefined, 'user', 'project-test');
+ // `targetLabels` now contains only validated display targets — unresolved
+ // garbage is excluded so the dashboard never renders it as a "to" header.
+ // The "had target intent" semantics live in `fallbackBroadcast=false` and
+ // the unresolved name is reported separately via `unresolvedTargets`.
+ expect(plan.targetLabels).toEqual([]);
+ expect(plan.unresolvedTargets).toEqual(['nonexistent']);
+ expect(plan.recipients).toEqual([]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ it('user with @user only: no fallback (semantic-only target = had intent)', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, '@user done!', undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([]);
+ expect(plan.recipients).toEqual([]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ it('@all sets fallbackBroadcast=false (explicit broadcast resolved)', () => {
+ const plan = registry.buildDeliveryPlan('user', '@all check status', undefined, 'user', 'project-test');
+ expect(plan.targetLabels).toEqual(['all']);
+ expect(plan.recipients.sort()).toEqual([agent1.name, agent2.name].sort());
+ expect(plan.concreteAssignees).toEqual([]);
+ expect(plan.fallbackBroadcast).toBe(false);
+ });
+
+ // ── Self-loop guard (rendered as "claude-2 → claude-2") ─────────────
+ // Even if a sender's own alias somehow ends up in the token list (e.g.
+ // legacy data, bug, or an explicit `to` param), the displayed
+ // targetLabels should never include the sender — there is no such thing
+ // as sending a message to yourself.
+
+ it('strips sender alias from targetLabels when echoed in body prose', () => {
+ // Simulates an LLM that types its own alias in prose for whatever reason.
+ const body = `@${agent2.name} and @${agent1.name} both — heads up`;
+ const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([agent2.name]);
+ expect(plan.targetLabels).not.toContain(agent1.name);
+ });
+
+ it('strips sender alias from targetLabels when passed via to param', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, 'hello', agent1.name, 'llm', 'project-test');
+ expect(plan.targetLabels).not.toContain(agent1.name);
+ });
+
+ it('LLM with only own alias in code example: targetLabels empty (real-world bug)', () => {
+ // The exact shape of the message that produced "claude-2 → claude-2"
+ // in the chat history: code-fence example containing the sender's own
+ // alias. After the parser fix this should not even tokenize, and after
+ // the sender-strip defense it cannot leak even if it did.
+ const body = 'try one of:\n```\n@claude fix\n@claude implement\n```';
+ const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([]);
+ expect(plan.recipients).toEqual([]);
+ });
+
+ // ── targetLabels must contain only validated targets (display sanity) ──
+ // Real-world bug: targetLabels was built from raw `tokens`, so any
+ // garbage the parser captured (markdown leftovers, unknown names, the
+ // literal comma-string from a comma-separated `to` param) leaked into
+ // the persisted `msg.to` and the dashboard renderer showed it as the
+ // "to" line — e.g. `claude-2 → mention,codex-3:**`.
+
+ it('targetLabels excludes @mention to nonexistent participant', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, '@nobody-here please', undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([]);
+ });
+
+ it('targetLabels still keeps @all literally (it is a valid display target)', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, '@all heads up', undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual(['all']);
+ });
+
+ it('targetLabels excludes @mention captured as raw token (defense in depth)', () => {
+ // Even if the parser regressed and produced a garbage token, the
+ // display layer must not surface it. Verified by mixing a real and a
+ // fake mention: only the real one should appear in targetLabels.
+ const body = `@${agent2.name} and @ghost-rider please`;
+ const plan = registry.buildDeliveryPlan(agent1.name, body, undefined, 'llm', 'project-test');
+ expect(plan.targetLabels).toEqual([agent2.name]);
+ });
+
+ it('targetLabels handles comma-split to param targeting two real recipients', () => {
+ const plan = registry.buildDeliveryPlan(agent1.name, 'multi target', `${agent2.name},${agent2.name}`, 'llm', 'project-test');
+ // Same name twice is deduped → exactly one entry
+ expect(plan.targetLabels).toEqual([agent2.name]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════
+// INTEGRATION TESTS — send() targeted delivery behavior
+// ═══════════════════════════════════════════════════════════════════
+
+describe('send() targeted PTY delivery', () => {
+ let writes1: string[];
+ let writes2: string[];
+ let writes3: string[];
+ let a1: { name: string };
+ let a2: { name: string };
+ let a3: { name: string };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ globalPtys.clear();
+ setActiveProject({ id: 'project-test', name: 'Test', path: '/tmp/test' });
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+
+ writes1 = [];
+ writes2 = [];
+ writes3 = [];
+ globalPtys.set('pane-7', { ptyProcess: { write: vi.fn((c: string) => { writes1.push(c); }) } as never, chunks: [], totalLen: 0 });
+ globalPtys.set('pane-14', { ptyProcess: { write: vi.fn((c: string) => { writes2.push(c); }) } as never, chunks: [], totalLen: 0 });
+ globalPtys.set('pane-15', { ptyProcess: { write: vi.fn((c: string) => { writes3.push(c); }) } as never, chunks: [], totalLen: 0 });
+ a1 = registry.join('claude', 'llm', 'pane-7', 'claude', '\r');
+ a2 = registry.join('codex', 'llm', 'pane-14', 'codex', '\r');
+ a3 = registry.join('cursor', 'llm', 'pane-15', 'cursor', '\r');
+ // Clear mocks AFTER joins — join messages don't pollute send() assertions
+ chatStoreMock.appendMessage.mockClear();
+ writes1.length = 0;
+ writes2.length = 0;
+ writes3.length = 0;
+ });
+
+ /** Advance timers enough for N sequential PTY deliveries to complete */
+ async function drainDeliveries(count: number): Promise {
+ for (let i = 0; i < count + 1; i++) {
+ await flushDeliveryQueue();
+ await vi.advanceTimersByTimeAsync(1100);
+ await flushDeliveryQueue();
+ }
+ }
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const p of registry.listParticipants()) registry.leave(p.name);
+ setActiveProject(null);
+ });
+
+ it('LLM @mention delivers to target only', async () => {
+ const p = registry.send(a1.name, `@${a2.name} review this`);
+ await drainDeliveries(1);
+ await p;
+
+ expect(writes2[0]).toBe(pty(a1.name, `@${a2.name} review this`));
+ expect(writes1).toEqual([]); // sender excluded
+ expect(writes3).toEqual([]); // a3 not mentioned
+ });
+
+ it('LLM with no @mention delivers to nobody', async () => {
+ const p = registry.send(a1.name, 'thinking out loud');
+ await flushDeliveryQueue();
+ await p;
+
+ expect(writes1).toEqual([]);
+ expect(writes2).toEqual([]);
+ expect(writes3).toEqual([]);
+ });
+
+ it('LLM @all delivers to all except sender', async () => {
+ const p = registry.send(a1.name, '@all heads up everyone');
+ await drainDeliveries(2);
+ await p;
+
+ expect(writes2[0]).toBe(pty(a1.name, '@all heads up everyone'));
+ expect(writes3[0]).toBe(pty(a1.name, '@all heads up everyone'));
+ expect(writes1).toEqual([]); // sender excluded
+ });
+
+ it('user with no @mention broadcasts to all (Option B)', async () => {
+ const p = registry.send('user', 'hello everyone');
+ await drainDeliveries(3);
+ await p;
+
+ expect(writes1[0]).toBe(pty('user', 'hello everyone'));
+ expect(writes2[0]).toBe(pty('user', 'hello everyone'));
+ expect(writes3[0]).toBe(pty('user', 'hello everyone'));
+ });
+
+ it('user @specific delivers to target only', async () => {
+ const p = registry.send('user', `@${a1.name} implement this`);
+ await drainDeliveries(1);
+ await p;
+
+ expect(writes1[0]).toBe(pty('user', `@${a1.name} implement this`));
+ expect(writes2).toEqual([]); // not mentioned
+ expect(writes3).toEqual([]); // not mentioned
+ });
+
+ it('message is always persisted regardless of delivery', async () => {
+ const p = registry.send(a1.name, 'no mentions');
+ await flushDeliveryQueue();
+ await p;
+ expect(chatStoreMock.appendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('deliveredTo is included in persisted message for targeted delivery', async () => {
+ const p = registry.send('user', `@${a1.name} do X`);
+ await drainDeliveries(1);
+ await p;
+
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.deliveredTo).toBe(1);
+ });
+
+ it('deliveredTo is absent when LLM has no mentions', async () => {
+ const p = registry.send(a1.name, 'no delivery');
+ await flushDeliveryQueue();
+ await p;
+
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.deliveredTo).toBeUndefined();
+ });
+
+ it('msg.to stores "all" for @all messages', async () => {
+ const p = registry.send(a1.name, '@all broadcast');
+ await drainDeliveries(2);
+ await p;
+
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0];
+ expect(sendCall.to).toBe('all');
+ });
+
+ // ── `to` param delivery tests (issue 2 coverage) ───────────────────
+
+ it('LLM with no body mention but to=target delivers to target only', async () => {
+ const p = registry.send(a1.name, 'review this please', a2.name);
+ await drainDeliveries(1);
+ await p;
+
+ expect(writes2[0]).toBe(pty(a1.name, 'review this please'));
+ expect(writes1).toEqual([]); // sender
+ expect(writes3).toEqual([]); // not targeted
+ });
+
+ it('user with no body mention but to=target delivers to target only (no Option B)', async () => {
+ const p = registry.send('user', 'implement this', a1.name);
+ await drainDeliveries(1);
+ await p;
+
+ expect(writes1[0]).toBe(pty('user', 'implement this'));
+ expect(writes2).toEqual([]); // not targeted
+ expect(writes3).toEqual([]); // not targeted
+ });
+
+ it('union: to=targetA and body @targetB delivers to both', async () => {
+ const p = registry.send(a1.name, `@${a3.name} check this too`, a2.name);
+ await drainDeliveries(2);
+ await p;
+
+ expect(writes2[0]).toBe(pty(a1.name, `@${a3.name} check this too`));
+ expect(writes3[0]).toBe(pty(a1.name, `@${a3.name} check this too`));
+ expect(writes1).toEqual([]); // sender
+ });
+
+ it('@all does NOT set participant status to working (concreteAssignees safety)', async () => {
+ const before = registry.listParticipants().map(p => ({ name: p.name, status: p.status }));
+
+ const p = registry.send('user', '@all check status');
+ await drainDeliveries(3);
+ await p;
+
+ const after = registry.listParticipants().map(p => ({ name: p.name, status: p.status }));
+ expect(after).toEqual(before);
+ });
+
+ // ── msg.to display format (recipient-garbage bug) ────────────────────
+ // The persisted `msg.to` field is what the dashboard renderer reads to
+ // build the `@sender → @t1, @t2` header. It must contain ONLY validated
+ // names (or 'all' for broadcast), separated by ", " for multi-target,
+ // never the literal comma-string from a comma-separated `to` param.
+
+ it('msg.to is single name for one targeted recipient', async () => {
+ const p = registry.send(a1.name, `@${a2.name} review this`);
+ await drainDeliveries(1);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe(a2.name);
+ });
+
+ it('msg.to is comma-space separated for multiple targeted recipients', async () => {
+ const p = registry.send(a1.name, `@${a2.name} @${a3.name} review`);
+ await drainDeliveries(2);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe(`${a2.name}, ${a3.name}`);
+ });
+
+ it('msg.to omits markdown-leaked garbage even with bold-wrapped mention', async () => {
+ const p = registry.send(a1.name, `**Coordination, @${a2.name}:** stand down`);
+ await drainDeliveries(1);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ // No `:**`, no `mention`, no garbage — just the validated participant.
+ expect(sendCall.to).toBe(a2.name);
+ });
+
+ it('msg.to omits unresolved garbage when body has fake mention', async () => {
+ // Even if a peer LLM produces an @mention to a nonexistent name,
+ // msg.to must only contain validated participants.
+ const p = registry.send(a1.name, `@${a2.name} @ghost-here please`);
+ await drainDeliveries(1);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe(a2.name);
+ // The unresolved one is reported separately, not in `to`.
+ expect(sendCall.unresolvedTargets).toContain('ghost-here');
+ });
+
+ it('msg.to handles comma-split to param targeting two real recipients', async () => {
+ // Caller passed `to: "a2,a3"` — server must split, not store as one token.
+ const p = registry.send(a1.name, 'multi target', `${a2.name},${a3.name}`);
+ await drainDeliveries(2);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe(`${a2.name}, ${a3.name}`);
+ });
+
+ // ── Implicit broadcast header (codex review feedback) ──────────────
+ // The user's example header `@user → @all` should appear for ALL user
+ // broadcasts, including unaddressed ones (Option B fallback). Without
+ // this, the dashboard renders no header for the user's typical pattern
+ // of plain unaddressed messages, which leaves the addressing intent
+ // invisible.
+
+ it('msg.to is "all" for implicit user broadcast (no @mention, fallback)', async () => {
+ const p = registry.send('user', 'hello everyone');
+ await drainDeliveries(3);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe('all');
+ });
+
+ it('msg.to is "all" for unaddressed system message (system also fallbacks)', async () => {
+ const p = registry.send('system', 'server restarted');
+ await drainDeliveries(3);
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBe('all');
+ });
+
+ it('msg.to stays null for LLM with no @mention (LLMs do not fallback)', async () => {
+ const p = registry.send(a1.name, 'thinking out loud');
+ await flushDeliveryQueue();
+ await p;
+ const sendCall = chatStoreMock.appendMessage.mock.calls[0][0] as Record;
+ expect(sendCall.to).toBeNull();
+ });
+});
diff --git a/src/apps/chat/services/chat-registry.test.ts b/src/apps/chat/services/chat-registry.test.ts
new file mode 100644
index 0000000..7063b6d
--- /dev/null
+++ b/src/apps/chat/services/chat-registry.test.ts
@@ -0,0 +1,881 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { globalPtys } from '../../shell/src/runtime/shell-state.js';
+import { setActiveProject } from '../../../project-context.js';
+
+const chatStoreMock = vi.hoisted(() => {
+ let seq = 0;
+ return {
+ appendMessage: vi.fn((msg: { from: string; to: string | null; body: string; type: string }) => ({
+ id: `msg-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ topic: null,
+ ...msg,
+ })),
+ appendPipeEvent: vi.fn((event: Record) => ({
+ id: `pipe-event-${++seq}`,
+ ts: new Date('2026-01-01T00:00:00.000Z').toISOString(),
+ ...event,
+ })),
+ clearMessages: vi.fn(),
+ readMessages: vi.fn(() => []),
+ reset: () => {
+ seq = 0;
+ },
+ };
+});
+
+vi.mock('./chat-store.js', () => ({
+ appendMessage: chatStoreMock.appendMessage,
+ appendPipeEvent: chatStoreMock.appendPipeEvent,
+ clearMessages: chatStoreMock.clearMessages,
+ readMessages: chatStoreMock.readMessages,
+ saveParticipants: vi.fn(),
+ loadParticipants: vi.fn(() => []),
+}));
+
+const registry = await import('./chat-registry.js');
+
+/** Format a chat message as it would appear in PTY delivery to an LLM participant. */
+function pty(
+ from: string,
+ body: string,
+ options?: { assignedBy?: 'pipe' | null },
+): string {
+ const tags = ['DevGlide Chat'];
+ if (options?.assignedBy) tags.push(`Assigned by: ${options.assignedBy}`);
+ return `[${tags.join(' | ')}] @${from}: ${body}`;
+}
+
+async function flushDeliveryQueue(): Promise {
+ await vi.advanceTimersByTimeAsync(0);
+}
+
+describe('chat-registry PTY delivery', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ chatStoreMock.appendPipeEvent.mockClear();
+ chatStoreMock.clearMessages.mockClear();
+ chatStoreMock.readMessages.mockReset();
+ chatStoreMock.readMessages.mockReturnValue([]);
+ globalPtys.clear();
+ setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' });
+
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ setActiveProject(null);
+ });
+
+ it('serializes back-to-back deliveries to the same pane', async () => {
+ const writes: string[] = [];
+ globalPtys.set('pane-1', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const participant = registry.join('codex', 'llm', 'pane-1', 'codex', '\r');
+
+ const firstSend = registry.send('user', 'first');
+ const secondSend = registry.send('user', 'second');
+ await flushDeliveryQueue();
+
+ expect(writes).toEqual([
+ pty('user', 'first'),
+ ]);
+
+ // First submit key after 1000ms (PTY_SUBMIT_DELAY_MS)
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writes).toEqual([
+ pty('user', 'first'),
+ '\r',
+ pty('user', 'second'),
+ ]);
+
+ // Second message: submit key after 1000ms
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writes).toEqual([
+ pty('user', 'first'),
+ '\r',
+ pty('user', 'second'),
+ '\r',
+ ]);
+
+ await firstSend;
+ await secondSend;
+
+ registry.leave(participant.name);
+ });
+
+ it('skips the delayed submit when the participant detaches before it fires', async () => {
+ const writes: string[] = [];
+ globalPtys.set('pane-2', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const participant = registry.join('claude', 'llm', 'pane-2', 'claude', '\r');
+
+ const sendPromise = registry.send('user', 'hello');
+ await flushDeliveryQueue();
+ registry.detach(participant.name);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writes).toEqual([
+ pty('user', 'hello'),
+ ]);
+
+ await sendPromise;
+
+ registry.leave(participant.name);
+ });
+
+ it('skips the delayed submit after same-pane detach and reclaim', async () => {
+ const writes: string[] = [];
+ globalPtys.set('pane-4', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const participant = registry.join('codex', 'llm', 'pane-4', 'codex', '\r');
+
+ const sendPromise = registry.send('user', 'reclaim-race');
+ await flushDeliveryQueue();
+ registry.detach(participant.name);
+ const reclaimed = registry.join('codex', 'llm', 'pane-4', 'codex', '\r');
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(reclaimed.name).toBe(participant.name);
+ expect(writes).toEqual([
+ pty('user', 'reclaim-race'),
+ ]);
+
+ await sendPromise;
+
+ registry.leave(participant.name);
+ });
+
+ it('announces REST-backed MCP adoption as a session upgrade', () => {
+ globalPtys.set('pane-upgrade', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ const participant = registry.join('codex', 'llm', 'pane-upgrade', 'codex', '\r', 'project-chat', 'rest');
+ chatStoreMock.appendMessage.mockClear();
+
+ const reclaimed = registry.join('codex', 'llm', 'pane-upgrade', 'codex', '\r', 'project-chat', 'mcp');
+
+ expect(reclaimed.name).toBe(participant.name);
+ expect(reclaimed.joinedVia).toBe('mcp');
+ expect(chatStoreMock.appendMessage).toHaveBeenCalledWith(expect.objectContaining({
+ from: participant.name,
+ body: `${participant.name} session upgraded (pane-upgrade)`,
+ type: 'join',
+ }), 'project-chat');
+
+ registry.leave(participant.name);
+ });
+
+ it('keeps reconnected wording for detached reclaim', () => {
+ globalPtys.set('pane-reconnect', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ const participant = registry.join('codex', 'llm', 'pane-reconnect', 'codex', '\r', 'project-chat', 'mcp');
+ registry.detach(participant.name);
+ chatStoreMock.appendMessage.mockClear();
+
+ const reclaimed = registry.join('codex', 'llm', 'pane-reconnect', 'codex', '\r', 'project-chat', 'mcp');
+
+ expect(reclaimed.name).toBe(participant.name);
+ expect(chatStoreMock.appendMessage).toHaveBeenCalledWith(expect.objectContaining({
+ from: participant.name,
+ body: `${participant.name} reconnected (pane-reconnect)`,
+ type: 'join',
+ }), 'project-chat');
+
+ registry.leave(participant.name);
+ });
+
+ it('skips the delayed submit when the pane closes before it fires', async () => {
+ const writes: string[] = [];
+ globalPtys.set('pane-3', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const participant = registry.join('cursor', 'llm', 'pane-3', 'cursor', '\r');
+
+ const sendPromise = registry.send('user', 'close-soon');
+ await flushDeliveryQueue();
+ globalPtys.delete('pane-3');
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writes).toEqual([
+ pty('user', 'close-soon'),
+ ]);
+ expect(registry.getParticipant(participant.name)?.paneId).toBeNull();
+
+ await sendPromise;
+
+ registry.leave(participant.name);
+ });
+
+ it('delivers across panes sequentially', async () => {
+ const writesA: string[] = [];
+ const writesB: string[] = [];
+
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writesA.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writesB.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const first = registry.join('first', 'llm', 'pane-a', 'claude', '\r');
+ const second = registry.join('second', 'llm', 'pane-b', 'codex', '\r');
+
+ const sendPromise = registry.send('user', 'ordered');
+ await flushDeliveryQueue();
+
+ expect(writesA).toEqual([pty('user', 'ordered')]);
+ expect(writesB).toEqual([]);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writesA).toEqual([pty('user', 'ordered'), '\r']);
+ expect(writesB).toEqual([pty('user', 'ordered')]);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ expect(writesB).toEqual([pty('user', 'ordered'), '\r']);
+
+ await sendPromise;
+
+ registry.leave(first.name);
+ registry.leave(second.name);
+ });
+
+ it('broadcasts mentioned messages to every same-project participant except the sender', async () => {
+ const writesA: string[] = [];
+ const writesB: string[] = [];
+ const writesSender: string[] = [];
+
+ globalPtys.set('pane-a', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writesSender.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-b', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writesA.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-c', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writesB.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const sender = registry.join('sender', 'llm', 'pane-a', 'codex', '\r');
+ const target = registry.join('target', 'llm', 'pane-b', 'claude', '\r');
+ const observer = registry.join('observer', 'llm', 'pane-c', 'cursor', '\r');
+
+ const sendPromise = registry.send(sender.name, `@${target.name} please handle this`);
+ await flushDeliveryQueue();
+
+ // Targeted delivery: only @mentioned target receives PTY, observer does not
+ expect(writesSender).toEqual([]);
+ expect(writesA).toEqual([pty(sender.name, `@${target.name} please handle this`)]);
+ expect(writesB).toEqual([]); // observer not mentioned — no delivery
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+
+ // Observer still empty — targeted delivery means non-mentioned participants don't receive
+ expect(writesB).toEqual([]);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ await sendPromise;
+
+ registry.leave(sender.name);
+ registry.leave(target.name);
+ registry.leave(observer.name);
+ });
+
+ it('adds pipe authority tags for compact pipe handoff notifications', async () => {
+ const writes: string[] = [];
+
+ globalPtys.set('pane-pipe-a', {
+ ptyProcess: { write: vi.fn((chunk: string) => { writes.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-pipe-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const first = registry.join('alice', 'llm', 'pane-pipe-a', 'claude', '\r');
+ const second = registry.join('bob', 'llm', 'pane-pipe-b', 'codex', '\r');
+
+ const sendPromise = registry.send('user', `/linear-pipe @${first.name} @${second.name} : audit the last changes`);
+ await flushDeliveryQueue();
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await sendPromise;
+
+ expect(writes.some(chunk => chunk.includes('[DevGlide Chat | Assigned by: pipe] @system: #pipe-'))).toBe(true);
+ expect(writes.some(chunk => chunk.includes('Inspect assignment: pipe_get_assignment'))).toBe(true);
+
+ registry.leave(first.name);
+ registry.leave(second.name);
+ });
+
+ it('does not append interaction reminder to any participant', async () => {
+ const llmWrites: string[] = [];
+ const userWrites: string[] = [];
+
+ globalPtys.set('pane-llm', {
+ ptyProcess: { write: vi.fn((chunk: string) => { llmWrites.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-user', {
+ ptyProcess: { write: vi.fn((chunk: string) => { userWrites.push(chunk); }) } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const llm = registry.join('claude', 'llm', 'pane-llm', 'claude', '\r');
+ const user = registry.join('tester', 'user', 'pane-user', null, '\r');
+
+ // A third participant sends a message — both should receive it via PTY
+ globalPtys.set('pane-sender', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ const sender = registry.join('codex', 'llm', 'pane-sender', 'codex', '\r');
+
+ // Use @all so the LLM message is broadcast (LLMs without @mention get no delivery)
+ const sendPromise = registry.send(sender.name, '@all hello everyone');
+
+ // Drain all deliveries
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await sendPromise;
+
+ // Neither LLM nor user participant gets the reminder
+ const llmMsg = llmWrites.find((w) => w.startsWith('[DevGlide Chat]'));
+ expect(llmMsg).toBeDefined();
+ expect(llmMsg).not.toContain('');
+
+ const userMsg = userWrites.find((w) => w.startsWith('[DevGlide Chat]'));
+ expect(userMsg).toBeDefined();
+ expect(userMsg).not.toContain('');
+
+ // Stored message has no reminder
+ const stored = chatStoreMock.appendMessage.mock.calls.find(
+ (c: unknown[]) => (c[0] as { body: string }).body === '@all hello everyone',
+ );
+ expect(stored).toBeDefined();
+ expect((stored![0] as { body: string }).body).not.toContain('');
+
+ registry.leave(llm.name);
+ registry.leave(user.name);
+ registry.leave(sender.name);
+ });
+
+ it('marks assigned participants as working and returns them to idle after inactivity', async () => {
+ globalPtys.set('pane-status-working', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const worker = registry.join('codex', 'llm', 'pane-status-working', 'codex', '\r');
+
+ const sendPromise = registry.send('user', `@${worker.name} fix the rendering bug`);
+ expect(registry.getParticipant(worker.name)?.status).toBe('working');
+
+ await vi.advanceTimersByTimeAsync(30_000);
+ await flushDeliveryQueue();
+ await sendPromise;
+
+ expect(registry.getParticipant(worker.name)?.status).toBe('idle');
+
+ registry.leave(worker.name);
+ });
+
+ it('marks explicit review assignments as working', async () => {
+ globalPtys.set('pane-status-review', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const reviewer = registry.join('claude', 'llm', 'pane-status-review', 'claude', '\r');
+
+ const sendPromise = registry.send('user', `@${reviewer.name} verify the fix`);
+
+ expect(registry.getParticipant(reviewer.name)?.status).toBe('working');
+
+ // Drain the delivery chain (submit delay)
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await sendPromise;
+
+ registry.leave(reviewer.name);
+ });
+
+ it('can still enumerate a joined project after the global active project changes', () => {
+ globalPtys.set('pane-5', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ const participant = registry.join('claude', 'llm', 'pane-5', 'claude', '\r');
+ setActiveProject({ id: 'project-other', name: 'Other', path: '/tmp/other' });
+
+ expect(registry.listParticipants()).toEqual([]);
+ expect(registry.listParticipants('project-chat').map((p) => p.name)).toEqual([participant.name]);
+
+ registry.leave(participant.name, 'project-chat');
+ });
+
+ it('same display name in two projects does not collide', () => {
+ globalPtys.set('pane-p1', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-p2', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+
+ // Join project-chat (active) as claude
+ const p1 = registry.join('claude', 'llm', 'pane-p1', 'claude', '\r');
+
+ // Switch to project-other and join as claude with same pane num
+ setActiveProject({ id: 'project-other', name: 'Other', path: '/tmp/other' });
+ const p2 = registry.join('claude', 'llm', 'pane-p2', 'claude', '\r', 'project-other');
+
+ // Both should coexist — same display name, different projects
+ expect(p1.name).toMatch(/^claude/);
+ expect(p2.name).toMatch(/^claude/);
+ expect(p1.projectId).toBe('project-chat');
+ expect(p2.projectId).toBe('project-other');
+
+ // Each project sees only its own participant
+ expect(registry.listParticipants('project-chat').map(p => p.name)).toEqual([p1.name]);
+ expect(registry.listParticipants('project-other').map(p => p.name)).toEqual([p2.name]);
+
+ // Leaving one does not affect the other
+ registry.leave(p2.name, 'project-other');
+ expect(registry.listParticipants('project-chat').map(p => p.name)).toEqual([p1.name]);
+
+ registry.leave(p1.name, 'project-chat');
+ });
+});
+
+describe('chat-registry PTY status detection (idle/working)', () => {
+ let dataListeners: Array<(data: string) => void>;
+
+ function createPtyWithOnData(paneId: string) {
+ dataListeners = [];
+ const mockEntry = {
+ ptyProcess: {
+ write: vi.fn(),
+ onData: vi.fn((listener: (data: string) => void) => {
+ dataListeners.push(listener);
+ return {
+ dispose: vi.fn(() => {
+ const idx = dataListeners.indexOf(listener);
+ if (idx >= 0) dataListeners.splice(idx, 1);
+ }),
+ };
+ }),
+ } as never,
+ chunks: [] as string[],
+ totalLen: 0,
+ };
+ globalPtys.set(paneId, mockEntry);
+ return mockEntry;
+ }
+
+ function emitPtyData(paneId: string, data: string) {
+ const entry = globalPtys.get(paneId) as { chunks: string[]; totalLen: number };
+ if (entry) {
+ entry.chunks.push(data);
+ entry.totalLen += data.length;
+ }
+ for (const listener of [...dataListeners]) {
+ listener(data);
+ }
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ chatStoreMock.clearMessages.mockClear();
+ chatStoreMock.readMessages.mockReset();
+ chatStoreMock.readMessages.mockReturnValue([]);
+ globalPtys.clear();
+ dataListeners = [];
+ setActiveProject({ id: 'project-chat', name: 'Chat', path: '/tmp/chat' });
+
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ globalPtys.clear();
+ for (const participant of registry.listParticipants()) {
+ registry.leave(participant.name);
+ }
+ setActiveProject(null);
+ });
+
+ // ── PTY-driven working status ──────────────────────────────────
+
+ it('sets working on nontrivial PTY output', async () => {
+ createPtyWithOnData('pane-pty-w1');
+ const participant = registry.join('claude', 'llm', 'pane-pty-w1', 'claude', '\r');
+ expect(registry.getParticipant(participant.name)?.status).toBe('idle');
+
+ emitPtyData('pane-pty-w1', 'Compiling src/main.ts...');
+
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ it('returns to idle after PTY inactivity timeout (8s)', async () => {
+ createPtyWithOnData('pane-pty-w2');
+ const participant = registry.join('claude', 'llm', 'pane-pty-w2', 'claude', '\r');
+
+ emitPtyData('pane-pty-w2', 'Building...');
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // After 8s of silence → idle
+ await vi.advanceTimersByTimeAsync(8000);
+
+ expect(registry.getParticipant(participant.name)?.status).toBe('idle');
+
+ registry.leave(participant.name);
+ });
+
+ it('does not set working on ANSI-only / whitespace-only output', async () => {
+ createPtyWithOnData('pane-pty-w3');
+ const participant = registry.join('claude', 'llm', 'pane-pty-w3', 'claude', '\r');
+
+ // Pure ANSI escape (cursor move) — no printable content
+ emitPtyData('pane-pty-w3', '\x1b[2J\x1b[H');
+
+ expect(registry.getParticipant(participant.name)?.status).toBe('idle');
+
+ registry.leave(participant.name);
+ });
+
+ it('keeps working status during PTY activity after review assignment', async () => {
+ createPtyWithOnData('pane-pty-w4');
+ const participant = registry.join('claude', 'llm', 'pane-pty-w4', 'claude', '\r');
+
+ const sendPromise = registry.send('user', `@${participant.name} verify the fix`);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // PTY output should keep the participant in working
+ emitPtyData('pane-pty-w4', 'Reading file...');
+
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // Drain the delivery chain (submit delay)
+ await vi.advanceTimersByTimeAsync(1000);
+ await flushDeliveryQueue();
+ await sendPromise;
+
+ registry.leave(participant.name);
+ });
+
+ // ── Prompt detection holds working ──────────────────────────────
+
+ it('holds working when PTY output matches a prompt pattern (prevents idle)', async () => {
+ createPtyWithOnData('pane-prompt-1');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-1', 'claude', '\r');
+
+ emitPtyData('pane-prompt-1', 'Allow Edit /src/file.ts');
+
+ // Nontrivial output → working immediately
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // After quiescence (2000ms), prompt detected → idle timer cancelled, stays working
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // Even after the normal 8s idle timeout, still working (prompt holds it)
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ it('detects MCP tool permission prompts with double underscores', async () => {
+ createPtyWithOnData('pane-prompt-mcp');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-mcp', 'claude', '\r');
+
+ emitPtyData('pane-prompt-mcp', 'Allow mcp__devglide-chat__chat_send({"message":"hello"})');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Prompt holds working indefinitely
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ it('detects generic yes/no prompts and holds working', async () => {
+ createPtyWithOnData('pane-prompt-yn');
+ const participant = registry.join('codex', 'llm', 'pane-prompt-yn', 'codex', '\r');
+
+ emitPtyData('pane-prompt-yn', 'Do you want to overwrite? (y/n)');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Prompt holds working
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ it('releases working→idle after prompt is answered (new nontrivial output)', async () => {
+ createPtyWithOnData('pane-prompt-answered');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-answered', 'claude', '\r');
+
+ // Trigger prompt hold
+ emitPtyData('pane-prompt-answered', 'Allow Bash npm test');
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // User responds — nontrivial output arrives, prompt flag clears after quiescence
+ emitPtyData('pane-prompt-answered', 'Running tests...');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Still working but now the idle timer is active
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // After 8s of inactivity → idle (prompt no longer holding)
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('idle');
+
+ registry.leave(participant.name);
+ });
+
+ it('does not re-trigger prompt hold from stale text after user responds', async () => {
+ createPtyWithOnData('pane-prompt-retrigger');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-retrigger', 'claude', '\r');
+
+ // First: prompt appears → held working
+ emitPtyData('pane-prompt-retrigger', 'Allow Edit /src/file.ts');
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // User responds → new output clears prompt flag
+ emitPtyData('pane-prompt-retrigger', 'File saved successfully');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Delta buffer was cleared so old "Allow Edit" should NOT re-trigger hold
+ // After 8s → should go idle
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('idle');
+
+ registry.leave(participant.name);
+ });
+
+ it('chat-injected PTY text does not clear prompt hold', async () => {
+ createPtyWithOnData('pane-prompt-chat-injected');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-chat-injected', 'claude', '\r');
+
+ // Prompt detected → held working
+ emitPtyData('pane-prompt-chat-injected', 'Allow WebFetch https://example.com');
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ // Chat-injected text arrives — should NOT clear the prompt hold
+ emitPtyData('pane-prompt-chat-injected', '[DevGlide Chat | Assigned by: user] @codex-2: checking now');
+ await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(8000);
+
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ it('detects lowercase allow prompts', async () => {
+ createPtyWithOnData('pane-prompt-lowercase');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-lowercase', 'claude', '\r');
+
+ emitPtyData('pane-prompt-lowercase', 'allow webfetch https://example.com');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Prompt holds working
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(registry.getParticipant(participant.name)?.status).toBe('working');
+
+ registry.leave(participant.name);
+ });
+
+ // ── Watcher lifecycle ─────────────────────────────────────────
+
+ it('cleans up watcher on leave', async () => {
+ const mockEntry = createPtyWithOnData('pane-prompt-6');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-6', 'claude', '\r');
+
+ expect((mockEntry.ptyProcess as { onData: ReturnType }).onData).toHaveBeenCalled();
+
+ registry.leave(participant.name);
+
+ expect(dataListeners.length).toBe(0);
+ });
+
+ it('cleans up watcher on detach', async () => {
+ createPtyWithOnData('pane-prompt-7');
+ const participant = registry.join('claude', 'llm', 'pane-prompt-7', 'claude', '\r');
+
+ registry.detach(participant.name);
+
+ expect(dataListeners.length).toBe(0);
+
+ registry.leave(participant.name);
+ });
+});
+
+describe('chat-registry cross-project isolation', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ chatStoreMock.reset();
+ chatStoreMock.appendMessage.mockClear();
+ chatStoreMock.readMessages.mockReset();
+ chatStoreMock.readMessages.mockReturnValue([]);
+ globalPtys.clear();
+ globalPtys.set('pane-xproj-a', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ globalPtys.set('pane-xproj-b', {
+ ptyProcess: { write: vi.fn() } as never,
+ chunks: [],
+ totalLen: 0,
+ });
+ setActiveProject({ id: 'project-a', name: 'A', path: '/tmp/a' });
+ for (const p of registry.listParticipants('project-a')) registry.leave(p.name, 'project-a');
+ for (const p of registry.listParticipants('project-b')) registry.leave(p.name, 'project-b');
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ for (const p of registry.listParticipants('project-a')) registry.leave(p.name, 'project-a');
+ for (const p of registry.listParticipants('project-b')) registry.leave(p.name, 'project-b');
+ globalPtys.clear();
+ setActiveProject(null);
+ });
+
+ it('listParticipants excludes participants from other projects', () => {
+ // Use distinct base names so cross-project identity is unambiguous
+ const a = registry.join('alice', 'llm', 'pane-xproj-a', 'claude', '\r', 'project-a');
+ const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b');
+
+ const aList = registry.listParticipants('project-a');
+ const bList = registry.listParticipants('project-b');
+
+ expect(aList.map((p) => p.name)).toEqual([a.name]);
+ expect(bList.map((p) => p.name)).toEqual([b.name]);
+ expect(aList.every((p) => p.projectId === 'project-a')).toBe(true);
+ expect(bList.every((p) => p.projectId === 'project-b')).toBe(true);
+ });
+
+ it('getParticipant(name) without projectId never returns a cross-project match', () => {
+ // Only project-b has a participant named "solo"; active project is project-a
+ const b = registry.join('solo', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b');
+ // Sanity: the participant exists in project-b
+ expect(registry.getParticipant(b.name, 'project-b')?.projectId).toBe('project-b');
+ // Legacy call (no projectId) must NOT leak the project-b match through the active (project-a) scope
+ expect(registry.getParticipant(b.name)).toBeUndefined();
+ });
+
+ it('getParticipantByPaneId(paneId) without projectId never returns a cross-project match', () => {
+ const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b');
+ // Sanity: explicit project lookup works
+ expect(registry.getParticipantByPaneId('pane-xproj-b', 'project-b')?.name).toBe(b.name);
+ // Active project is project-a — lookup without projectId must NOT leak project-b
+ expect(registry.getParticipantByPaneId('pane-xproj-b')).toBeUndefined();
+ });
+
+ it('send does not PTY-deliver to a cross-project @mention target', async () => {
+ // Distinct base names so @bob exists only in project-b and cannot resolve in project-a
+ registry.join('alice', 'llm', 'pane-xproj-a', 'claude', '\r', 'project-a');
+ const b = registry.join('bob', 'llm', 'pane-xproj-b', 'claude', '\r', 'project-b');
+ const writeA = (globalPtys.get('pane-xproj-a')!.ptyProcess as unknown as { write: ReturnType }).write;
+ const writeB = (globalPtys.get('pane-xproj-b')!.ptyProcess as unknown as { write: ReturnType }).write;
+ writeA.mockClear();
+ writeB.mockClear();
+
+ // User in project-a @-mentions a name that exists only in project-b.
+ const sendPromise = registry.send('user', `@${b.name} please implement`, undefined, 'project-a');
+ await flushDeliveryQueue();
+ const msg = await sendPromise;
+
+ // project-b participant must not have been written to
+ expect(writeB).not.toHaveBeenCalled();
+ // project-a participant must not be the target either — the token was unresolved
+ expect(writeA).not.toHaveBeenCalled();
+ // The persisted message must list the cross-project token as unresolved
+ expect(msg.unresolvedTargets ?? []).toContain(b.name);
+ });
+});
diff --git a/src/apps/chat/services/chat-registry.ts b/src/apps/chat/services/chat-registry.ts
new file mode 100644
index 0000000..a12ae4a
--- /dev/null
+++ b/src/apps/chat/services/chat-registry.ts
@@ -0,0 +1,2386 @@
+import type { Namespace } from 'socket.io';
+import type { ChatParticipant, ChatMessage, PipeMessageMeta, PipeUiEvent } from '../types.js';
+import { globalPtys, dashboardState, getShellNsp } from '../../shell/src/runtime/shell-state.js';
+import { appendMessage, appendPipeEvent, readMessages, clearMessages, saveParticipants, loadParticipants, discoverPersistedPipeIds, readAllPipeEvents, removePipeFiles } from './chat-store.js';
+import type { PersistedParticipant } from './chat-store.js';
+import { getActiveProject, onProjectChange } from '../../../project-context.js';
+import { isPipeCommand, parsePipeCommand, isPipeParseError, validatePipeAssigneeCount, isBrainstormCommand, parseBrainstormCommand } from './pipe-parser.js';
+import * as brainstormStore from './brainstorm-store.js';
+import * as pipeReducer from './pipe-reducer.js';
+import * as pipeStore from './pipe-store.js';
+import * as pipeDelivery from './pipe-delivery.js';
+import * as assignmentQueries from './pipe-assignment-queries.js';
+import * as provenance from './pipe-provenance.js';
+import * as materializer from './pipe-assignment-materializer.js';
+import * as payloadStore from './payload-store.js';
+import { stripAnsi } from './terminal-utils.js';
+
+// In-memory participant registry
+const participants = new Map();
+let chatNsp: Namespace | null = null;
+const paneDeliveryQueues = new Map>();
+const participantSessionEpochs = new Map();
+const participantStatusTimers = new Map>();
+const panePromptWatchers = new Map void }>();
+
+const PTY_SUBMIT_DELAY_MS = 1000;
+
+const PARTICIPANT_IDLE_TIMEOUT_MS = 30_000;
+const PROMPT_QUIESCENCE_MS = 2000;
+const PANE_DISCONNECT_TIMEOUT_MS = 10_000; // 10 seconds before auto-removal
+
+// ── Pipe reliability constants ──────────────────────────────────────────────
+const PIPE_WATCHDOG_INTERVAL_MS = 5_000; // 5 seconds — pane liveness + deadline check
+
+const paneDisconnectTimers = new Map>();
+
+// ── Pipe stage deadline timers ──────────────────────────────────────────────
+// Keyed by "pipeId:assignee" — one timer per active lease
+const stageDeadlineTimers = new Map>();
+let pipeWatchdogInterval: ReturnType | null = null;
+
+function bumpParticipantSessionEpoch(name: string, projectId?: string | null): number {
+ const key = participantKey(name, projectId);
+ const next = (participantSessionEpochs.get(key) ?? 0) + 1;
+ participantSessionEpochs.set(key, next);
+ return next;
+}
+
+function currentParticipantSessionEpoch(name: string, projectId?: string | null): number {
+ return participantSessionEpochs.get(participantKey(name, projectId)) ?? 0;
+}
+
+function participantKey(name: string, projectId?: string | null): string {
+ return `${projectId ?? '__none__'}:${name}`;
+}
+
+function getParticipantExact(name: string, projectId?: string | null): ChatParticipant | undefined {
+ return participants.get(participantKey(name, projectId));
+}
+
+function activeProjectId(): string | null {
+ return getActiveProject()?.id ?? null;
+}
+
+function resolveProjectId(projectId?: string | null): string | null {
+ return projectId ?? activeProjectId();
+}
+
+function emitMembers(projectId?: string | null): void {
+ emitToProject('chat:members', listParticipants(projectId), projectId);
+}
+
+function clearParticipantStatusTimer(name: string, projectId?: string | null): void {
+ const key = participantKey(name, projectId);
+ const timer = participantStatusTimers.get(key);
+ if (timer) {
+ clearTimeout(timer);
+ participantStatusTimers.delete(key);
+ }
+}
+
+function setParticipantStatus(
+ name: string,
+ projectId: string | null,
+ status: ChatParticipant['status'],
+ resetIdleTimer = true,
+): void {
+ const participant = getParticipantExact(name, projectId);
+ if (!participant || participant.kind !== 'llm') return;
+ const changed = participant.status !== status;
+ participant.status = status;
+ if (resetIdleTimer) {
+ clearParticipantStatusTimer(name, projectId);
+ if (status !== 'idle') {
+ const key = participantKey(name, projectId);
+ participantStatusTimers.set(key, setTimeout(() => {
+ participantStatusTimers.delete(key);
+ const current = getParticipantExact(name, projectId);
+ if (!current || current.kind !== 'llm') return;
+ current.status = 'idle';
+ emitMembers(projectId);
+ }, PARTICIPANT_IDLE_TIMEOUT_MS));
+ }
+ }
+ if (changed) emitMembers(projectId);
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function getMessageAuthority(
+ targetName: string,
+ msg: ChatMessage,
+): 'pipe' | null {
+ if (
+ msg.from === 'system'
+ && msg.pipe?.targetAssignee === targetName
+ && (msg.pipe.role === 'handoff' || msg.pipe.role === 'fan-out-request' || msg.pipe.role === 'synth-request')
+ ) {
+ return 'pipe';
+ }
+
+ return null;
+}
+
+export function _getMessageAuthorityForTest(
+ targetName: string,
+ _projectId: string | null,
+ msg: ChatMessage,
+): 'pipe' | null {
+ return getMessageAuthority(targetName, msg);
+}
+
+function formatPtyHeader(targetName: string, msg: ChatMessage): string {
+ const tags = ['DevGlide Chat'];
+
+ const authority = getMessageAuthority(targetName, msg);
+ if (authority) tags.push(`Assigned by: ${authority}`);
+
+ return `[${tags.join(' | ')}]`;
+}
+
+function markAssignedParticipantStatus(body: string, targetName: string): ChatParticipant['status'] | null {
+ const lowered = body.toLowerCase();
+ const targetMention = `@${targetName.toLowerCase()}`;
+ const reviewTerms = '(verify|verification|review|check|inspect|validate|confirm|test)';
+ const reviewRe = new RegExp(`(${escapeRegExp(targetMention)}\\b[^\\n]{0,120}\\b${reviewTerms}\\b|\\b${reviewTerms}\\b[^\\n]{0,120}${escapeRegExp(targetMention)}\\b)`, 'i');
+ if (reviewRe.test(lowered)) return 'working';
+
+ const workTerms = '(fix|handle|implement|patch|update|investigate|look\\s+into|take|pick\\s+up|work\\s+on|resolve|debug)';
+ const workRe = new RegExp(`(${escapeRegExp(targetMention)}\\b[^\\n]{0,120}\\b${workTerms}\\b|\\b${workTerms}\\b[^\\n]{0,120}${escapeRegExp(targetMention)}\\b)`, 'i');
+ return workRe.test(lowered) ? 'working' : null;
+}
+
+
+// ── PTY activity & prompt detection ───────────────────────────────────────
+// Watches linked pane output for:
+// 1. Nontrivial output → set 'working' (with inactivity timer → 'idle')
+// 2. Known prompt patterns (y/n, tool approval) → hold 'working' (cancel idle timer)
+// 3. New nontrivial output after prompt → clear prompt flag, resume normal idle cycle
+//
+// Prompt detection uses a delta buffer (output since last quiescence check)
+// rather than the full scrollback tail, preventing stale prompts from
+// re-triggering after the user has already responded.
+
+/** Returns true if text contains printable (non-whitespace) characters after ANSI stripping. */
+function hasNontrivialContent(rawData: string): boolean {
+ const stripped = stripAnsi(rawData);
+ return /\S/.test(stripped);
+}
+
+function hasNontrivialText(text: string): boolean {
+ return /\S/.test(text);
+}
+
+function isChatInjectedOutput(text: string): boolean {
+ return /^\[DevGlide Chat(?: \| Assigned by: [a-z-]+)*\] @\S+:/m.test(text.trim());
+}
+
+const AWAITING_USER_PATTERNS: RegExp[] = [
+ // Claude Code tool permission prompts
+ /Allow\s+(?:Read|Edit|Write|Bash|MultiEdit|NotebookEdit|Glob|Grep|WebFetch|WebSearch|Agent|Skill|mcp_+[\w-]+)/i,
+ // "wants to use/run" phrasing (Claude Code, similar tools)
+ /wants to (?:use|read|edit|write|run|execute|create|delete)\b/i,
+ // Generic yes/no confirmation at end of line
+ /\(y\/n\)\s*$/m,
+ /\[y\/n\]\s*$/im,
+ /\[yes\/no\]\s*$/im,
+ // Press to continue
+ /press (?:enter|any key|y) to (?:continue|proceed|confirm)/i,
+ // Generic approval / permission prompts
+ /\b(?:approval|permission)\b.{0,80}\b(?:required|needed|requested)\b/i,
+ /\b(?:approve|allow|confirm)\b.{0,80}\b(?:tool|command|action|request)\b/i,
+];
+
+function matchesPromptPattern(text: string): boolean {
+ return AWAITING_USER_PATTERNS.some(re => re.test(text));
+}
+
+const PTY_WORKING_IDLE_TIMEOUT_MS = 8000;
+
+function startPanePromptWatcher(name: string, projectId: string | null, paneId: string): void {
+ const key = participantKey(name, projectId);
+ stopPanePromptWatcher(key);
+
+ const entry = globalPtys.get(paneId);
+ if (!entry?.ptyProcess?.onData) return;
+
+ let quiescenceTimer: ReturnType | null = null;
+ let idleTimer: ReturnType | null = null;
+ let promptVisible = false;
+ // Delta buffer: collects output since the last quiescence check,
+ // so prompt detection only scans recent output, not stale history.
+ let deltaBuffer = '';
+
+ const disposable = entry.ptyProcess.onData((data: string) => {
+ deltaBuffer += data;
+ const participant = getParticipantExact(name, projectId);
+
+ // PTY-driven working: nontrivial output → set working
+ if (hasNontrivialContent(data)) {
+ if (participant && participant.kind === 'llm' && !participant.detached) {
+ setParticipantStatus(name, projectId, 'working', false);
+ // Reset inactivity timer → idle (unless a prompt is holding working)
+ if (idleTimer) clearTimeout(idleTimer);
+ if (!promptVisible) {
+ idleTimer = setTimeout(() => {
+ idleTimer = null;
+ const p = getParticipantExact(name, projectId);
+ if (p && p.kind === 'llm' && p.status === 'working') {
+ setParticipantStatus(name, projectId, 'idle');
+ }
+ }, PTY_WORKING_IDLE_TIMEOUT_MS);
+ }
+ }
+ }
+
+ // Debounce: check for prompt pattern after output settles
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
+ quiescenceTimer = setTimeout(() => {
+ quiescenceTimer = null;
+ const participant = getParticipantExact(name, projectId);
+ if (!participant || participant.kind !== 'llm' || participant.detached) return;
+
+ // Scan only the delta buffer (output since last check), not full scrollback
+ const stripped = stripAnsi(deltaBuffer);
+ deltaBuffer = '';
+
+ if (matchesPromptPattern(stripped)) {
+ // Prompt detected → hold working, cancel idle timer
+ promptVisible = true;
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
+ setParticipantStatus(name, projectId, 'working', false);
+ return;
+ }
+
+ if (promptVisible) {
+ // New output after prompt — if nontrivial and not chat-injected, prompt was answered
+ if (!hasNontrivialText(stripped) || isChatInjectedOutput(stripped)) return;
+
+ promptVisible = false;
+ setParticipantStatus(name, projectId, 'working', false);
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ idleTimer = null;
+ const p = getParticipantExact(name, projectId);
+ if (p && p.kind === 'llm' && p.status === 'working') {
+ setParticipantStatus(name, projectId, 'idle');
+ }
+ }, PTY_WORKING_IDLE_TIMEOUT_MS);
+ return;
+ }
+
+ // No prompt, no special state — let the idle timer run its course
+ }, PROMPT_QUIESCENCE_MS);
+ });
+
+ panePromptWatchers.set(key, {
+ dispose: () => {
+ disposable.dispose();
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
+ if (idleTimer) clearTimeout(idleTimer);
+ },
+ });
+}
+
+function stopPanePromptWatcher(key: string): void {
+ const watcher = panePromptWatchers.get(key);
+ if (watcher) {
+ watcher.dispose();
+ panePromptWatchers.delete(key);
+ }
+}
+
+// ── Participant persistence ──────────────────────────────────────────────────
+
+/** Persist current LLM participants to disk for a given project. */
+export function persistParticipantsForProject(projectId: string | null): void {
+ if (!projectId) return;
+ const llmParticipants: PersistedParticipant[] = [];
+ for (const p of participants.values()) {
+ if (p.kind !== 'llm' || p.projectId !== projectId) continue;
+ llmParticipants.push({
+ name: p.name,
+ model: p.model,
+ paneId: p.paneId,
+ projectId: p.projectId,
+ submitKey: p.submitKey,
+ joinedAt: p.joinedAt,
+ lastSeen: p.lastSeen,
+ joinedVia: p.joinedVia,
+ permissionMode: p.permissionMode,
+ });
+ }
+ saveParticipants(llmParticipants, projectId);
+}
+
+/** Restore participants from disk after server restart.
+ * Only reattaches participants whose pane still exists and matches exactly.
+ * Returns arrays of restored and failed participants. */
+export function restoreParticipants(projectId: string | null): { restored: string[]; failed: string[] } {
+ if (!projectId) return { restored: [], failed: [] };
+ const persisted = loadParticipants(projectId);
+ if (persisted.length === 0) return { restored: [], failed: [] };
+
+ const restored: string[] = [];
+ const failed: string[] = [];
+
+ for (const p of persisted) {
+ // Only reattach if pane + project match exactly
+ if (!p.paneId || !globalPtys.has(p.paneId)) {
+ failed.push(p.name);
+ continue;
+ }
+
+ // Check the pane still belongs to this project
+ const paneInfo = dashboardState.panes.find(d => d.id === p.paneId);
+ if (paneInfo?.projectId && p.projectId && paneInfo.projectId !== p.projectId) {
+ failed.push(p.name);
+ continue;
+ }
+
+ // Reattach — create participant in detached state, ready for reclaim
+ const key = participantKey(p.name, p.projectId);
+ if (participants.has(key)) continue; // already exists (shouldn't happen after restart)
+
+ const participant: ChatParticipant = {
+ name: p.name,
+ kind: 'llm',
+ model: p.model,
+ paneId: p.paneId,
+ paneNum: getPaneDisplayNumber(p.paneId),
+ projectId: p.projectId,
+ submitKey: p.submitKey,
+ joinedAt: p.joinedAt,
+ lastSeen: new Date().toISOString(),
+ detached: true, // detached until the MCP session reclaims
+ status: 'idle',
+ joinedVia: p.joinedVia ?? null,
+ permissionMode: p.permissionMode ?? paneInfo?.permissionMode ?? 'supervised',
+ };
+ participants.set(key, participant);
+ bumpParticipantSessionEpoch(p.name, p.projectId);
+ restored.push(p.name);
+ }
+
+ // Do NOT persist here — failed entries should stay in the file so they
+ // remain available for manual rejoin. They will be removed when the
+ // participant explicitly leaves or the disconnect timeout fires.
+
+ return { restored, failed };
+}
+
+export function setChatNsp(nsp: Namespace): void {
+ chatNsp = nsp;
+}
+
+/** Emit to all dashboard clients viewing the given project (or active project). */
+function emitToProject(event: string, data: unknown, projectId?: string | null): void {
+ const pid = projectId ?? activeProjectId();
+ if (!chatNsp) return;
+ if (pid) {
+ chatNsp.to(`project:${pid}`).emit(event, data);
+ } else {
+ // Fallback: no project context — broadcast to all (shouldn't happen in practice)
+ chatNsp.emit(event, data);
+ }
+}
+
+function emitPipeEvent(event: Omit, projectId?: string | null): PipeUiEvent {
+ const stored = appendPipeEvent(event, projectId);
+ emitToProject('chat:pipe', stored, projectId);
+ return stored;
+}
+
+function ensurePipeAnchor(body: string, pipeId: string): string {
+ const anchor = `#pipe-${pipeId}`;
+ return body.includes(anchor) ? body : `${anchor} ${body}`;
+}
+
+// Emit refreshed member list when the active project changes
+onProjectChange((project) => {
+ emitMembers(project?.id);
+});
+
+export function getChatNsp(): Namespace | null {
+ return chatNsp;
+}
+
+// ── Identity-based name assignment ──────────────────────────────────────────
+// Names are derived from hint (the `name` param from chat_join) + the numeric
+// suffix from the pane ID (e.g. "claude-5" for pane-5, "codex-4" for pane-4).
+// The `hint` is preferred over `model` so that agents with a stable identity
+// label (like "codex") keep that label regardless of which backend model they
+// report.
+
+/** Extract the numeric suffix from the pane ID (e.g. "pane-5" → 5). */
+function getPaneDisplayNumber(paneId: string | null): number | null {
+ if (!paneId) return null;
+ const match = paneId.match(/-(\d+)$/);
+ return match ? Number(match[1]) : null;
+}
+
+/** Normalize the identity base from hint/model (e.g. "claude", "codex"). */
+export function deriveNameBase(hint: string, model: string | null): string {
+ return (hint || model || 'agent').toLowerCase().replace(/[^a-z0-9-]/g, '');
+}
+
+/** Derive a name from hint/model + pane display number (e.g. "claude-1"). */
+function deriveUniqueName(hint: string, model: string | null, paneId: string | null, projectId: string | null): string {
+ // Prefer hint (the name param from chat_join) over model — this ensures
+ // agents like "codex" keep a stable identity even if model varies.
+ const base = deriveNameBase(hint, model);
+ const paneNum = getPaneDisplayNumber(paneId);
+
+ // Use model-paneNumber format (e.g. "claude-1")
+ const name = paneNum ? `${base}-${paneNum}` : base;
+
+ // If somehow still taken within this project, append a sequential suffix
+ const usedNames = new Set(
+ [...participants.values()]
+ .filter((p) => p.projectId === projectId)
+ .map((p) => p.name)
+ );
+ if (!usedNames.has(name)) return name;
+
+ let i = 1;
+ while (usedNames.has(`${name}-${i}`)) i++;
+ return `${name}-${i}`;
+}
+
+/** Update the shell pane tab title to show the chat name. */
+function permissionModeLabel(mode?: string | null): string {
+ if (!mode || mode === 'supervised') return '';
+ return mode === 'auto-accept' ? ' [AUTO]' : ' [UNRESTRICTED]';
+}
+
+function updatePaneTitle(paneId: string, chatName: string): void {
+ const pane = dashboardState.panes.find(p => p.id === paneId);
+ if (!pane) return;
+ pane.chatName = chatName;
+ const modeLabel = permissionModeLabel(pane.permissionMode);
+ pane.title = `${pane.num}: ${chatName}${modeLabel}`;
+ // Notify shell page to update the tab (separate from terminal:cwd so CWD changes don't overwrite)
+ getShellNsp()?.emit('state:pane-chat-name', { id: paneId, chatName: `${chatName}${modeLabel}` });
+}
+
+/** Find an existing participant that can be reclaimed by projectId + paneId + identity.
+ * The pane is the stable anchor, and the name base (e.g. "claude", "codex") must match
+ * the existing participant's name prefix so a different agent on the same pane won't
+ * steal the wrong alias. */
+function findReclaimCandidate(paneId: string | null, nameBase: string, projectId: string | null): ChatParticipant | null {
+ if (!paneId) return null;
+ for (const p of participants.values()) {
+ if (p.paneId === paneId && p.projectId === projectId && (p.name === nameBase || p.name.startsWith(`${nameBase}-`))) return p;
+ }
+ return null;
+}
+
+export function join(
+ name: string,
+ kind: 'user' | 'llm',
+ paneId: string | null,
+ model: string | null = null,
+ submitKey: string = '\r',
+ projectId?: string | null,
+ joinedVia?: 'rest' | 'mcp' | null,
+): ChatParticipant {
+ const now = new Date().toISOString();
+ const resolvedProjectId = resolveProjectId(projectId);
+
+ // Claim-or-create: try to reclaim an existing participant by paneId + identity
+ const nameBase = deriveNameBase(name, model);
+ const existing = findReclaimCandidate(paneId, nameBase, resolvedProjectId);
+ if (existing) {
+ const wasDetached = existing.detached;
+ const previousJoinVia = existing.joinedVia ?? null;
+ // Reattach: keep the same alias, update session fields
+ existing.detached = false;
+ existing.paneId = paneId;
+ existing.paneNum = getPaneDisplayNumber(paneId);
+ existing.model = model; // refresh — model may vary between sessions
+ existing.submitKey = submitKey;
+ existing.lastSeen = now;
+ existing.status = 'idle';
+ existing.joinedVia = joinedVia ?? existing.joinedVia ?? null;
+ const reclaimPane = paneId ? dashboardState.panes.find(p => p.id === paneId) : null;
+ existing.permissionMode = reclaimPane?.permissionMode ?? existing.permissionMode ?? 'supervised';
+ clearParticipantStatusTimer(existing.name, resolvedProjectId);
+ bumpParticipantSessionEpoch(existing.name, resolvedProjectId);
+ // Cancel any pending auto-removal timer
+ const disconnectKey = participantKey(existing.name, resolvedProjectId);
+ const disconnectTimer = paneDisconnectTimers.get(disconnectKey);
+ if (disconnectTimer) { clearTimeout(disconnectTimer); paneDisconnectTimers.delete(disconnectKey); }
+
+ if (paneId) updatePaneTitle(paneId, existing.name);
+
+ const joinAnnouncement =
+ !wasDetached && previousJoinVia === 'rest' && joinedVia === 'mcp'
+ ? 'session upgraded'
+ : 'reconnected';
+ const msg = appendMessage({
+ from: existing.name,
+ to: null,
+ body: `${existing.name} ${joinAnnouncement}${paneId ? ` (${paneId})` : ''}`,
+ type: 'join',
+ }, existing.projectId);
+ emitToProject('chat:join', existing, existing.projectId);
+ emitToProject('chat:message', msg, existing.projectId);
+ emitMembers(existing.projectId);
+
+ if (paneId) startPanePromptWatcher(existing.name, existing.projectId, paneId);
+ persistParticipantsForProject(existing.projectId);
+
+ // Reconcile any pending pipe assignments after reconnect
+ if (existing.kind === 'llm') {
+ reconcileOnReconnect(existing.name, existing.projectId);
+ }
+
+ return { ...existing };
+ }
+
+ // No reclaim candidate — derive name from model/identity
+ const uniqueName = deriveUniqueName(name, model, paneId, resolvedProjectId);
+ const paneInfo = paneId ? dashboardState.panes.find(p => p.id === paneId) : null;
+ const participant: ChatParticipant = {
+ name: uniqueName,
+ kind,
+ model,
+ paneId,
+ paneNum: getPaneDisplayNumber(paneId),
+ projectId: resolvedProjectId,
+ submitKey,
+ joinedAt: now,
+ lastSeen: now,
+ status: kind === 'llm' ? 'idle' : undefined,
+ detached: false,
+ joinedVia: joinedVia ?? null,
+ permissionMode: paneInfo?.permissionMode ?? 'supervised',
+ };
+ participants.set(participantKey(uniqueName, resolvedProjectId), participant);
+ bumpParticipantSessionEpoch(uniqueName, resolvedProjectId);
+
+ // Update the pane tab to show the chat name
+ if (paneId) updatePaneTitle(paneId, uniqueName);
+
+ const msg = appendMessage({
+ from: uniqueName,
+ to: null,
+ body: `${uniqueName} joined${paneId ? ` (${paneId})` : ''}`,
+ type: 'join',
+ }, participant.projectId);
+ emitToProject('chat:join', participant, participant.projectId);
+ emitToProject('chat:message', msg, participant.projectId);
+ emitMembers(participant.projectId);
+
+ if (paneId) startPanePromptWatcher(uniqueName, participant.projectId, paneId);
+ persistParticipantsForProject(participant.projectId);
+
+ return { ...participant };
+}
+
+export function leave(name: string, projectId?: string | null): boolean {
+ const participant = projectId !== undefined
+ ? getParticipantExact(name, projectId)
+ : getParticipant(name);
+ if (!participant) return false;
+ const pid = participant.projectId;
+ const removed = participants.delete(participantKey(name, pid));
+ if (removed) {
+ const key = participantKey(name, pid);
+ clearParticipantStatusTimer(name, pid);
+ stopPanePromptWatcher(key);
+ participantSessionEpochs.delete(key);
+ const disconnectTimer = paneDisconnectTimers.get(key);
+ if (disconnectTimer) { clearTimeout(disconnectTimer); paneDisconnectTimers.delete(key); }
+ const msg = appendMessage({
+ from: name,
+ to: null,
+ body: `${name} left`,
+ type: 'leave',
+ }, pid);
+ emitToProject('chat:leave', { name }, pid);
+ emitToProject('chat:message', msg, pid);
+ emitMembers(pid);
+ persistParticipantsForProject(pid);
+
+ // Fail-fast: cancel any running pipes this participant is in
+ failPipesForParticipant(name, pid, 'left');
+ }
+ return removed;
+}
+
+/** Mark a participant as detached (MCP session closed but pane still alive).
+ * The alias stays reserved so a subsequent join from the same pane + model reclaims it. */
+export function detach(name: string, projectId?: string | null): boolean {
+ const participant = projectId !== undefined
+ ? getParticipantExact(name, projectId)
+ : getParticipant(name);
+ if (!participant) return false;
+ participant.detached = true;
+ clearParticipantStatusTimer(name, participant.projectId);
+ stopPanePromptWatcher(participantKey(name, participant.projectId));
+ bumpParticipantSessionEpoch(name, participant.projectId);
+ emitMembers(participant.projectId);
+
+ // Fail-fast: cancel any running pipes this participant is in
+ failPipesForParticipant(name, participant.projectId, 'detached');
+
+ // Start auto-removal timer — if not reclaimed within timeout, fully remove
+ const key = participantKey(name, participant.projectId);
+ const existing = paneDisconnectTimers.get(key);
+ if (existing) clearTimeout(existing);
+ paneDisconnectTimers.set(key, setTimeout(() => {
+ paneDisconnectTimers.delete(key);
+ const p = getParticipantExact(name, participant.projectId);
+ if (p && p.detached) {
+ leave(name, participant.projectId);
+ }
+ }, PANE_DISCONNECT_TIMEOUT_MS));
+
+ return true;
+}
+
+function pruneStaleParticipants(): void {
+ for (const participant of [...participants.values()]) {
+ if (participant.kind !== 'llm' || !participant.paneId) continue;
+ if (globalPtys.has(participant.paneId)) continue;
+ // Pane is gone — detach gracefully instead of removing
+ disconnectParticipant(participant.name, participant.projectId, 'pane disappeared');
+ }
+}
+
+/** Gracefully disconnect a participant: unlink pane, keep in registry, start auto-removal timer. */
+function disconnectParticipant(name: string, projectId: string | null, reason: string): void {
+ const participant = getParticipantExact(name, projectId);
+ if (!participant) return;
+
+ participant.paneId = null;
+ participant.detached = true;
+ clearParticipantStatusTimer(name, projectId);
+ stopPanePromptWatcher(participantKey(name, projectId));
+ bumpParticipantSessionEpoch(name, projectId);
+ emitMembers(projectId);
+ persistParticipantsForProject(projectId);
+
+ // Fail-fast: cancel any running pipes this participant is in
+ const pipeReason = reason === 'pane closed' ? 'pane-closed' : 'detached';
+ failPipesForParticipant(name, projectId, pipeReason as 'left' | 'detached' | 'pane-closed');
+
+ // Start auto-removal timer — if not reclaimed within timeout, fully remove
+ const key = participantKey(name, projectId);
+ const existing = paneDisconnectTimers.get(key);
+ if (existing) clearTimeout(existing);
+ paneDisconnectTimers.set(key, setTimeout(() => {
+ paneDisconnectTimers.delete(key);
+ const p = getParticipantExact(name, projectId);
+ if (p && p.detached) {
+ leave(name, projectId);
+ }
+ }, PANE_DISCONNECT_TIMEOUT_MS));
+}
+
+export async function send(from: string, body: string, to?: string, projectId?: string | null): Promise {
+ pruneStaleParticipants();
+
+ // Update lastSeen — use project-scoped lookup when available
+ const resolvedPid = resolveProjectId(projectId);
+ const sender = resolvedPid ? getParticipantExact(from, resolvedPid) : getParticipant(from);
+ if (sender) sender.lastSeen = new Date().toISOString();
+
+ // Determine sender kind for routing rules
+ const senderKind = sender?.kind ?? (from === 'user' ? 'user' : 'llm');
+
+ // Use the sender's project — NOT the global active project.
+ // For dashboard/user sends (no participant record), fall back to activeProjectId().
+ const senderProjectId = sender?.projectId ?? activeProjectId();
+ const resolvedSenderProjectId = resolveProjectId(senderProjectId);
+
+ // ─── Brainstorm command detection (user-only) ──────────────────────
+ if (from === 'user' && isBrainstormCommand(body)) {
+ return handleBrainstormCommand(body, resolvedSenderProjectId);
+ }
+
+ // ─── Pipe command detection (user-only) ────────────────────────────
+ if (from === 'user' && isPipeCommand(body)) {
+ return handlePipeCommand(body, resolvedSenderProjectId);
+ }
+
+ // ─── Pipe response detection (LLM-only, log-centric) ──────────────
+ // For store-tracked pipes, chat_send is NEVER treated as a pipe response.
+ // Participants must use pipe_submit for store-tracked pipes.
+ let pipeMeta: PipeMessageMeta | undefined;
+ if (from !== 'system' && from !== 'user' && resolvedSenderProjectId) {
+ pipeMeta = detectPipeResponse(from, body, resolvedSenderProjectId);
+ // If the detected pipe is tracked in the store, suppress auto-detection.
+ // This prevents regular chat from being classified as pipe output.
+ if (pipeMeta && pipeStore.getPipe(pipeMeta.pipeId, resolvedSenderProjectId)) {
+ pipeMeta = undefined;
+ }
+ // Ensure #pipe-{id} anchor is always in the stored body for searchability
+ if (pipeMeta) body = ensurePipeAnchor(body, pipeMeta.pipeId);
+ }
+
+ // ─── Build delivery plan (targeted PTY delivery) ────────────────────
+ const plan = buildDeliveryPlan(from, body, to, senderKind, resolvedSenderProjectId);
+
+ if (sender?.kind === 'llm' && sender.projectId === resolvedSenderProjectId && sender.status && sender.status !== 'idle') {
+ setParticipantStatus(sender.name, resolvedSenderProjectId, sender.status);
+ }
+ // Status side-effects use concreteAssignees only — NOT recipients.
+ // This prevents @all from setting every agent to "working".
+ if (senderKind === 'user') {
+ for (const targetName of plan.concreteAssignees) {
+ const status = markAssignedParticipantStatus(body, targetName);
+ if (status) setParticipantStatus(targetName, resolvedSenderProjectId, status);
+ }
+ }
+
+ // Display `to` field — what the dashboard renders as `@sender → `.
+ // For explicit targets, list the validated names. For implicit user/system
+ // broadcasts (Option B fallback), show "all" so the header reads
+ // `@user → @all` instead of being silently absent.
+ const displayTo = plan.targetLabels.length === 1
+ ? plan.targetLabels[0]
+ : plan.targetLabels.length > 1
+ ? plan.targetLabels.join(', ')
+ : plan.fallbackBroadcast
+ ? 'all'
+ : null;
+
+ // ─── Compute delivery count BEFORE persisting ──────────────────────
+ // So deliveredTo is included in the persisted message and socket emit.
+ let expectedDeliveryCount: number;
+ if (plan.recipients.length > 0) {
+ expectedDeliveryCount = plan.recipients.length;
+ } else if (plan.fallbackBroadcast) {
+ // Count broadcast targets (Option B fallback)
+ expectedDeliveryCount = 0;
+ for (const p of participants.values()) {
+ if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) {
+ expectedDeliveryCount++;
+ }
+ }
+ } else {
+ expectedDeliveryCount = 0;
+ }
+
+ const msg = appendMessage({
+ from,
+ to: displayTo,
+ body,
+ type: 'message',
+ ...(pipeMeta ? { pipe: pipeMeta } : {}),
+ ...(expectedDeliveryCount > 0 ? { deliveredTo: expectedDeliveryCount } : {}),
+ ...(plan.unresolvedTargets.length > 0 ? { unresolvedTargets: plan.unresolvedTargets } : {}),
+ }, resolvedSenderProjectId);
+
+ // Emit to dashboard clients viewing this project only
+ emitToProject('chat:message', msg, resolvedSenderProjectId);
+
+ // ─── Targeted PTY delivery ─────────────────────────────────────────
+ // Deliver only to resolved recipients. If no recipients and sender is
+ // user/system, fall back to broadcast (Option B backward compat).
+ // LLM messages with no @mention: NO PTY delivery (token savings).
+ if (plan.recipients.length > 0) {
+ for (const name of plan.recipients) {
+ await deliverToPty(name, resolvedSenderProjectId, msg);
+ }
+ } else if (plan.fallbackBroadcast) {
+ // Option B: unaddressed user/system messages still broadcast
+ for (const p of participants.values()) {
+ if (p.name !== from && p.paneId && p.projectId === resolvedSenderProjectId) {
+ await deliverToPty(p.name, resolvedSenderProjectId, msg);
+ }
+ }
+ }
+ // LLM with no @mention and no fallback: no PTY delivery.
+ // Message is persisted (above) and visible in dashboard — just not PTY-injected.
+
+ // ─── Pipe reducer: check if this message triggers next step ────────
+ // NOTE: pipeMeta is only set for legacy pipes NOT tracked in the store.
+ // Store-tracked pipes are suppressed above — they require pipe_submit.
+ if (pipeMeta) {
+ runPipeReducer(pipeMeta.pipeId, resolvedSenderProjectId)
+ .catch(err => console.error('[pipe] reducer failed:', err));
+ }
+
+ return msg;
+}
+
+// ─── Targeted PTY Delivery — Two-stage target resolution ────────────
+
+/** Reserved pseudo-targets that are semantic only (no PTY delivery). */
+const SEMANTIC_ONLY_TARGETS = new Set(['user', 'system']);
+
+/** Strip fenced code blocks (```...```) and inline code spans (`...`) from
+ * body text so the @mention regex doesn't pick up example syntax as real
+ * recipients. Replaces them with whitespace so character offsets stay sane
+ * and adjacent tokens don't accidentally fuse together. */
+function stripCodeRegions(body: string): string {
+ // Fenced first (greedy on whole blocks; non-greedy on the inner content).
+ // Matches ``` optionally followed by a language tag, then anything until
+ // the next ``` on its own boundary.
+ let stripped = body.replace(/```[\s\S]*?```/g, (m) => ' '.repeat(m.length));
+ // Inline code spans — single backticks. Run after fenced so we don't bite
+ // into the fence markers themselves.
+ stripped = stripped.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
+ return stripped;
+}
+
+/** Extract raw @mention tokens from message body (pure string parsing, no state).
+ * Returns tokens like ["all"], ["claude-7", "codex-14"], ["team-ui"], or [].
+ * Handles explicit `to` param (which may be comma-separated), merging with
+ * body @mentions. Mentions inside inline code spans and fenced code blocks
+ * are ignored — they are example syntax, not real addressees. */
+// senderKind kept in signature for backward compatibility / future use; the
+// merge behavior is identical for user and llm senders.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function parseTargetTokens(body: string, to?: string, senderKind?: 'user' | 'llm'): string[] {
+ const tokens: string[] = [];
+
+ // Explicit `to` param — split on commas, trim, lowercase, drop empties.
+ // Real-world: callers sometimes pass `to: "codex-3,pi-1"` instead of a
+ // single name. Splitting here keeps the rest of the pipeline simple and
+ // prevents the literal comma-string from leaking into displayed `msg.to`.
+ if (to) {
+ for (const raw of to.split(',')) {
+ const normalized = raw.trim().toLowerCase();
+ if (normalized && !tokens.includes(normalized)) tokens.push(normalized);
+ }
+ }
+
+ // Scan body for all @mentions, but only outside code regions. The
+ // capture is restricted to `[a-zA-Z0-9-]+` (letters, digits, hyphens)
+ // so markdown formatting (`**`, `_`, `~`), trailing punctuation, and
+ // parentheses cannot leak into the token. Note: underscore is excluded
+ // because it's a markdown emphasis marker (`_@claude_`); chat aliases
+ // in DevGlide use `-` as the separator. This is the parser-side
+ // defense; `buildDeliveryPlan` adds a second defense by filtering
+ // tokens that don't resolve to a real participant.
+ const scannable = stripCodeRegions(body);
+ const regex = /@([a-zA-Z0-9-]+)/g;
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(scannable)) !== null) {
+ const token = match[1];
+ if (token && !tokens.includes(token)) tokens.push(token);
+ }
+
+ return tokens;
+}
+
+/** Expand raw target tokens into concrete participant names for PTY delivery.
+ * Returns { recipients, concreteAssignees } — recipients is the full delivery list,
+ * concreteAssignees is direct @mentions only (no group expansions) for status side-effects. */
+export function expandToRecipients(
+ tokens: string[],
+ from: string,
+ projectId: string | null,
+): { recipients: string[]; concreteAssignees: string[]; unresolvedTargets: string[] } {
+ const recipientSet = new Set();
+ const concreteSet = new Set();
+ const unresolvedSet = new Set();
+ const pid = resolveProjectId(projectId);
+
+ for (const token of tokens) {
+ if (token === 'all') {
+ // @all → every live, non-detached participant except sender
+ for (const p of participants.values()) {
+ if (p.name !== from && p.projectId === pid && !p.detached && p.paneId) {
+ recipientSet.add(p.name);
+ }
+ }
+ // @all does NOT add to concreteAssignees — it's a group expansion
+ } else if (SEMANTIC_ONLY_TARGETS.has(token)) {
+ // @user, @system — semantic only, no PTY delivery target
+ continue;
+ } else {
+ // Individual participant name
+ const p = getParticipantExact(token, pid);
+ if (p && p.projectId === pid && p.name !== from) {
+ concreteSet.add(p.name); // direct @mention → concrete assignee (always, for status)
+ // Only add to recipients if live and deliverable (not detached, has pane)
+ if (!p.detached && p.paneId) {
+ recipientSet.add(p.name);
+ }
+ } else if (!p || p.projectId !== pid) {
+ // Token doesn't match any known participant in this project
+ unresolvedSet.add(token);
+ }
+ }
+ }
+
+ return {
+ recipients: [...recipientSet],
+ concreteAssignees: [...concreteSet],
+ unresolvedTargets: [...unresolvedSet],
+ };
+}
+
+/** Build a complete delivery plan from message content and sender context.
+ * Combines token parsing + recipient expansion + fallback logic. */
+export function buildDeliveryPlan(
+ from: string,
+ body: string,
+ to: string | undefined,
+ senderKind: 'user' | 'llm',
+ projectId: string | null,
+): import('../types.js').DeliveryPlan {
+ const tokens = parseTargetTokens(body, to, senderKind);
+ const { recipients, concreteAssignees, unresolvedTargets } = expandToRecipients(tokens, from, projectId);
+
+ // Determine fallback: ONLY truly unaddressed user/system messages broadcast (Option B).
+ // If the sender wrote @mentions that didn't resolve (typo, offline, semantic-only),
+ // that is NOT "unaddressed" — they intended a target, it just failed. No fallback.
+ const hadTargetIntent = tokens.length > 0;
+ const fallbackBroadcast = !hadTargetIntent && recipients.length === 0
+ && (senderKind === 'user' || from === 'system');
+
+ // Display label list: only tokens that resolved to a real participant
+ // or the literal `all` group expansion. Unresolved garbage (markdown
+ // leaks, typos, the literal comma-string from a comma-separated `to`
+ // param, semantic-only `user`/`system`) is excluded so the dashboard
+ // renderer never shows it. Sender alias is also excluded — sending to
+ // yourself is nonsense. Order from `tokens` is preserved.
+ const fromLower = from.toLowerCase();
+ const concreteSet = new Set(concreteAssignees);
+ const targetLabels: string[] = [];
+ for (const token of tokens) {
+ if (token === fromLower) continue;
+ if (token === 'all' || concreteSet.has(token)) {
+ if (!targetLabels.includes(token)) targetLabels.push(token);
+ }
+ }
+
+ return { targetLabels, recipients, concreteAssignees, fallbackBroadcast, unresolvedTargets };
+}
+
+/** @deprecated Use buildDeliveryPlan() instead. Kept for backward compatibility during migration. */
+function resolveTargets(from: string, body: string, to?: string, senderKind?: 'user' | 'llm', projectId?: string | null): string[] {
+ const plan = buildDeliveryPlan(from, body, to, senderKind ?? 'llm', projectId ?? null);
+ return plan.concreteAssignees;
+}
+
+function deliverToPty(targetName: string, projectId: string | null, msg: ChatMessage): Promise {
+ const target = getParticipantExact(targetName, projectId);
+ if (!target?.paneId || target.detached) {
+ return Promise.resolve();
+ }
+
+ const paneId = target.paneId;
+ const sessionEpoch = currentParticipantSessionEpoch(targetName, projectId);
+ const previous = paneDeliveryQueues.get(paneId) ?? Promise.resolve();
+ const next = previous
+ .catch(() => {})
+ .then(async () => {
+ const liveTarget = getParticipantExact(targetName, projectId);
+ if (!liveTarget?.paneId || liveTarget.detached || liveTarget.paneId !== paneId || currentParticipantSessionEpoch(targetName, projectId) !== sessionEpoch) {
+ return;
+ }
+
+ const entry = globalPtys.get(paneId);
+ if (!entry) {
+ disconnectParticipant(targetName, projectId, 'pane disappeared during delivery');
+ return;
+ }
+
+ const header = formatPtyHeader(targetName, msg);
+ let formatted = `${header} @${msg.from}: ${msg.body}`;
+
+ // Write with retry — if the initial write fails, retry once after a short delay
+ let writeOk = false;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ const ptyEntry = attempt === 0 ? entry : globalPtys.get(paneId);
+ if (!ptyEntry) {
+ disconnectParticipant(targetName, projectId, 'pane disappeared during delivery retry');
+ return;
+ }
+ ptyEntry.ptyProcess.write(formatted);
+ writeOk = true;
+ break;
+ } catch (err) {
+ if (attempt === 0) {
+ console.warn(`[chat] PTY write failed for ${targetName}, retrying in 500ms:`, err);
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ } else {
+ console.error(`[chat] PTY write retry failed for ${targetName}, disconnecting:`, err);
+ disconnectParticipant(targetName, projectId, 'pane write failed');
+ return;
+ }
+ }
+ }
+ if (!writeOk) return;
+
+ await new Promise((resolve) => setTimeout(resolve, PTY_SUBMIT_DELAY_MS));
+
+ const refreshed = getParticipantExact(targetName, projectId);
+ if (!refreshed?.paneId || refreshed.detached || refreshed.paneId !== paneId || currentParticipantSessionEpoch(targetName, projectId) !== sessionEpoch) {
+ return;
+ }
+
+ const refreshedEntry = globalPtys.get(paneId);
+ if (!refreshedEntry) {
+ disconnectParticipant(targetName, projectId, 'pane disappeared before submit');
+ return;
+ }
+
+ refreshedEntry.ptyProcess.write(refreshed.submitKey);
+ })
+ .finally(() => {
+ if (paneDeliveryQueues.get(paneId) === next) {
+ paneDeliveryQueues.delete(paneId);
+ }
+ });
+
+ paneDeliveryQueues.set(paneId, next);
+ return next;
+}
+
+export function listParticipants(projectId?: string | null): ChatParticipant[] {
+ pruneStaleParticipants();
+
+ const pid = resolveProjectId(projectId);
+ const result: ChatParticipant[] = [];
+ for (const p of participants.values()) {
+ // Only return participants that belong to the active project
+ if (p.projectId === pid) {
+ result.push({ ...p });
+ }
+ }
+ result.sort((a, b) => a.name.localeCompare(b.name));
+ return result;
+}
+
+function comparePipeAssigneeOrder(a: ChatParticipant, b: ChatParticipant): number {
+ const byJoin = a.joinedAt.localeCompare(b.joinedAt);
+ if (byJoin !== 0) return byJoin;
+ return a.name.localeCompare(b.name);
+}
+
+export function listDefaultPipeAssignees(projectId?: string | null): ChatParticipant[] {
+ pruneStaleParticipants();
+
+ const pid = resolveProjectId(projectId);
+ return [...participants.values()]
+ .filter((participant) =>
+ participant.projectId === pid
+ && participant.kind === 'llm'
+ && !participant.detached
+ && !!participant.paneId)
+ .sort(comparePipeAssigneeOrder);
+}
+
+export function getParticipant(name: string, projectId?: string | null): ChatParticipant | undefined {
+ // Exact lookup when projectId is provided
+ if (projectId !== undefined) return getParticipantExact(name, projectId);
+ // No projectId supplied: scope strictly to the active project — never fall back
+ // to a cross-project match, even when it is the only match by name.
+ const pid = activeProjectId();
+ return getParticipantExact(name, pid);
+}
+
+export function getParticipantByPaneId(paneId: string, projectId?: string | null): ChatParticipant | undefined {
+ pruneStaleParticipants();
+
+ // Determine the scope: explicit projectId if given, otherwise the active project.
+ // Under no circumstances return a participant whose projectId differs from the scope.
+ const pid = projectId !== undefined ? projectId : activeProjectId();
+ for (const participant of participants.values()) {
+ if (participant.paneId === paneId && participant.projectId === pid) return participant;
+ }
+ return undefined;
+}
+
+/** Clear chat history for the active project and notify dashboard clients. */
+export function clearHistory(projectId?: string | null): void {
+ const pid = resolveProjectId(projectId);
+ clearMessages(pid);
+ emitToProject('chat:cleared', {}, pid);
+}
+
+/** Clean up stale terminal pipes from both in-memory store and disk.
+ * Removes completed/failed/cancelled pipes older than the TTL.
+ * Returns the count of removed pipes. */
+export function cleanupStalePipes(projectId?: string | null, ttlMs?: number): number {
+ const pid = resolveProjectId(projectId);
+ const removed = pipeStore.cleanupTerminalPipes(pid, ttlMs);
+ if (removed.length > 0) {
+ removePipeFiles(removed, pid);
+ console.log(`[pipe] Cleaned up ${removed.length} stale pipe(s): ${removed.join(', ')}`);
+ }
+ return removed.length;
+}
+
+/** Recover active pipes from persisted event logs after server restart.
+ * Rebuilds in-memory pipe state from per-pipe events files.
+ * Pipes that were running at shutdown are rehydrated; the reducer is re-run
+ * for each recovered pipe so leases can be re-granted when participants rejoin.
+ * Returns the count of recovered running pipes. */
+export function recoverPipes(projectId?: string | null): number {
+ const pid = resolveProjectId(projectId);
+ const pipeIds = discoverPersistedPipeIds(pid);
+ if (pipeIds.length === 0) return 0;
+
+ // Collect all events across all pipe files
+ const allEvents: import('./pipe-store.js').PipeRecoveryEvent[] = [];
+ for (const pipeId of pipeIds) {
+ // Skip if already in memory (shouldn't happen after fresh start)
+ if (pipeStore.getPipe(pipeId, pid)) continue;
+
+ const events = readAllPipeEvents(pipeId, pid);
+ for (const event of events) {
+ allEvents.push({
+ type: event.type,
+ pipeId: event.pipeId,
+ mode: event.mode ?? undefined,
+ assignees: event.assignees,
+ prompt: event.prompt,
+ stageTimeoutMs: event.stageTimeoutMs,
+ timeoutPolicy: event.timeoutPolicy,
+ from: event.from,
+ role: event.role,
+ stage: event.stage,
+ content: event.content,
+ });
+ }
+ }
+
+ const runningPipeIds = pipeStore.rehydrateFromEvents(allEvents, pid);
+
+ if (runningPipeIds.length > 0) {
+ console.log(`[pipe] Recovered ${runningPipeIds.length} running pipe(s) from disk: ${runningPipeIds.join(', ')}`);
+ startPipeWatchdog();
+ }
+
+ return runningPipeIds.length;
+}
+
+// ── Reconnect assignment reconciliation ──────────────────────────────────────
+
+/** Reconcile pipe assignments when a participant reconnects (or joins for the
+ * first time after server restart with recovered pipes).
+ *
+ * For each running pipe where the participant has pending or leased slots,
+ * re-run the reducer so that:
+ * - Pending slots get a lease grant + PTY handoff delivery
+ * - Leased slots whose deadline expired get released and reset to pending
+ * - Leased slots still within deadline get re-delivered to the now-live pane
+ *
+ * Returns the number of pipes that were reconciled. */
+export function reconcileOnReconnect(name: string, projectId: string | null): number {
+ const assignments = pipeStore.getAssignmentsForParticipant(name, projectId);
+ if (assignments.length === 0) return 0;
+
+ const pipeIds = new Set();
+ for (const a of assignments) {
+ if (a.slotStatus === 'pending' || a.slotStatus === 'leased') {
+ pipeIds.add(a.pipeId);
+ }
+ }
+
+ if (pipeIds.size === 0) return 0;
+
+ for (const pipeId of pipeIds) {
+ const lease = pipeStore.getActiveLease(name, projectId);
+ if (lease && lease.pipeId === pipeId && pipeStore.isLeaseExpired(lease)) {
+ pipeStore.releaseLease(name, projectId);
+ const pipe = pipeStore.getPipe(pipeId, projectId);
+ if (pipe) {
+ const slots = pipe.slots.get(name);
+ if (slots) {
+ for (const slot of slots) {
+ if (slot.status === 'leased') slot.status = 'pending';
+ }
+ }
+ }
+ }
+
+ runPipeReducer(pipeId, projectId).catch((err) => {
+ console.error(`[pipe] reconcileOnReconnect reducer error for pipe #${pipeId}:`, err);
+ });
+ }
+
+ console.log(`[pipe] Reconciled ${pipeIds.size} pipe(s) for reconnected participant "${name}"`);
+ return pipeIds.size;
+}
+
+
+/** Handle pane closure — gracefully disconnect participants linked to this pane.
+ * Participants are detached (not removed) so they can reclaim within the timeout window.
+ * Scoped by projectId to avoid affecting participants from other projects. */
+export function onPaneClosed(paneId: string, projectId?: string | null): void {
+ for (const p of [...participants.values()]) {
+ if (p.paneId === paneId && (projectId == null || p.projectId === projectId)) {
+ disconnectParticipant(p.name, p.projectId, 'pane closed');
+ }
+ }
+}
+
+
+// ── Pipe stage deadline management ──────────────────────────────────────────
+
+function deadlineKey(pipeId: string, assignee: string): string {
+ return `${pipeId}:${assignee}`;
+}
+
+/** Start a deadline timer for a leased pipe stage.
+ * When the timer fires, the timeout policy is applied. */
+function startStageDeadline(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+ timeoutMs: number,
+ policy: import('../types.js').PipeTimeoutPolicy,
+): void {
+ if (timeoutMs <= 0) return; // no timeout configured
+ const key = deadlineKey(pipeId, assignee);
+ const existing = stageDeadlineTimers.get(key);
+ if (existing) clearTimeout(existing);
+ stageDeadlineTimers.set(key, setTimeout(() => {
+ stageDeadlineTimers.delete(key);
+ handleStageTimeout(pipeId, assignee, projectId, policy);
+ }, timeoutMs));
+}
+
+/** Clear a specific stage deadline (e.g. after successful submit). */
+function clearStageDeadline(pipeId: string, assignee: string): void {
+ const key = deadlineKey(pipeId, assignee);
+ const timer = stageDeadlineTimers.get(key);
+ if (timer) {
+ clearTimeout(timer);
+ stageDeadlineTimers.delete(key);
+ }
+}
+
+/** Clear all deadline timers for a pipe (e.g. when pipe reaches terminal state). */
+function clearAllDeadlinesForPipe(pipeId: string): void {
+ for (const [key, timer] of stageDeadlineTimers) {
+ if (key.startsWith(`${pipeId}:`)) {
+ clearTimeout(timer);
+ stageDeadlineTimers.delete(key);
+ }
+ }
+}
+
+/** Handle a stage timeout by applying the configured policy. */
+function handleStageTimeout(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+ policy: import('../types.js').PipeTimeoutPolicy,
+): void {
+ const pipe = pipeStore.getPipe(pipeId, projectId);
+ if (!pipe || pipe.status !== 'running') return;
+
+ if (policy === 'escalate') {
+ // Notify user, keep pipe running — user decides what to do
+ const escalateMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${pipeId} Stage timeout: @${assignee} has not responded within the deadline ` +
+ `(${Math.round(pipe.stageTimeoutMs / 1000)}s). The pipe is still running. ` +
+ `Cancel with \`/cancel-pipe ${pipeId}\` or wait for the participant to respond.`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', escalateMsg, projectId);
+ return;
+ }
+
+ // 'fail' (default) or 'reassign' (not yet implemented — falls through to fail)
+ clearAllDeadlinesForPipe(pipeId);
+ const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'failed', projectId);
+ provenance.recordProvenance(projectId, { pipeId, event: 'failed', actor: 'system', actorKind: 'system', metadata: { reason: 'timeout', assignee, policy } });
+ const policyNote = policy === 'reassign'
+ ? ' (reassign policy not yet supported — pipe failed instead)'
+ : '';
+ const failMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${pipeId} Pipe timed out: @${assignee} did not submit within the deadline ` +
+ `(${Math.round(pipe.stageTimeoutMs / 1000)}s).${policyNote}`,
+ type: 'system',
+ pipe: { pipeId, mode: pipe.mode, role: 'failed', reason: 'timeout' },
+ }, projectId);
+ emitToProject('chat:message', failMsg, projectId);
+ emitPipeEvent({ type: 'failed', pipeId, reason: 'timeout' }, projectId);
+ drainPendingPipes(releasedAssignees, projectId);
+}
+
+// ── Pipe liveness watchdog ──────────────────────────────────────────────────
+
+const PIPE_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
+let lastCleanupAt = 0;
+
+/** Periodic watchdog that checks pane liveness for active pipe leaseholders
+ * and enforces stage deadlines. Runs every PIPE_WATCHDOG_INTERVAL_MS. */
+function pipeWatchdogTick(): void {
+ // 1. Prune stale participants (detect disappeared panes)
+ pruneStaleParticipants();
+
+ // 2. Check stage deadlines for leases that passed their deadline but whose
+ // timer hasn't fired yet (defensive — timers should handle this, but
+ // the watchdog catches edge cases like clock drift or timer GC)
+ const now = Date.now();
+ for (const [, lease] of pipeStore.getAllActiveLeases()) {
+ if (!lease.deadline) continue;
+ const deadlineMs = new Date(lease.deadline).getTime();
+ if (now >= deadlineMs && !stageDeadlineTimers.has(deadlineKey(lease.pipeId, lease.assignee))) {
+ // Deadline passed and no active timer — resolve immediately
+ const pipe = pipeStore.getPipe(lease.pipeId, null) ??
+ findPipeAcrossProjects(lease.pipeId);
+ if (pipe && pipe.status === 'running') {
+ handleStageTimeout(lease.pipeId, lease.assignee, findProjectForPipe(lease.pipeId), pipe.timeoutPolicy);
+ }
+ }
+ }
+
+ // 3. Periodic cleanup of terminal pipes (throttled to every 10 minutes)
+ // Iterates all projects with pipe data, not just the active one.
+ if (now - lastCleanupAt >= PIPE_CLEANUP_INTERVAL_MS) {
+ lastCleanupAt = now;
+ for (const pid of pipeStore.getTrackedProjectIds()) {
+ const removed = pipeStore.cleanupTerminalPipes(pid);
+ if (removed.length > 0) {
+ removePipeFiles(removed, pid);
+ }
+ }
+ }
+}
+
+/** Find a pipe by scanning all project stores. */
+function findPipeAcrossProjects(pipeId: string): import('./pipe-store.js').StoredPipe | undefined {
+ // Try active project first, then scan all
+ const pid = activeProjectId();
+ const pipe = pipeStore.getPipe(pipeId, pid);
+ if (pipe) return pipe;
+ return undefined;
+}
+
+/** Find the projectId that owns a pipe by checking the active project. */
+function findProjectForPipe(pipeId: string): string | null {
+ const pid = activeProjectId();
+ if (pipeStore.getPipe(pipeId, pid)) return pid;
+ return null;
+}
+
+/** Start the pipe watchdog interval. Idempotent — safe to call multiple times. */
+export function startPipeWatchdog(): void {
+ if (pipeWatchdogInterval) return;
+ pipeWatchdogInterval = setInterval(pipeWatchdogTick, PIPE_WATCHDOG_INTERVAL_MS);
+ // Don't prevent Node from exiting
+ if (pipeWatchdogInterval.unref) pipeWatchdogInterval.unref();
+}
+
+/** Stop the pipe watchdog. Exported for test cleanup. */
+export function stopPipeWatchdog(): void {
+ if (pipeWatchdogInterval) {
+ clearInterval(pipeWatchdogInterval);
+ pipeWatchdogInterval = null;
+ }
+}
+
+/** Clear all deadline timers. Exported for test cleanup. */
+export function clearAllDeadlineTimers(): void {
+ for (const [key, timer] of stageDeadlineTimers) {
+ clearTimeout(timer);
+ stageDeadlineTimers.delete(key);
+ }
+}
+
+// ── Pipe orchestration (log-centric reducer model) ───────────────────────────
+
+async function handlePipeCommand(body: string, projectId: string | null): Promise {
+ const parsed = parsePipeCommand(body, (name) => getParticipantExact(name, projectId) != null);
+ if (isPipeParseError(parsed)) {
+ const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId);
+ emitToProject('chat:message', userMsg, projectId);
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Pipe error: ${parsed.error}`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ // Store command (not PTY-delivered)
+ const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId);
+ emitToProject('chat:message', userMsg, projectId);
+
+ const resolvedAssignees = parsed.assignees.length > 0
+ ? parsed.assignees
+ : listDefaultPipeAssignees(projectId).map((participant) => participant.name);
+
+ const countError = validatePipeAssigneeCount(parsed.mode, resolvedAssignees.length);
+ if (countError) {
+ const detail = parsed.assignees.length === 0
+ ? ' No eligible default LLM assignees were available.'
+ : '';
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Pipe error: ${countError}${detail}`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ // Validate all assignees are connected, live LLM participants
+ const invalid: string[] = [];
+ const reasons: string[] = [];
+ for (const a of resolvedAssignees) {
+ const p = getParticipantExact(a, projectId);
+ if (!p) { invalid.push(a); reasons.push(`@${a} not found`); continue; }
+ if (p.kind !== 'llm') { invalid.push(a); reasons.push(`@${a} is not an LLM`); continue; }
+ if (p.detached) { invalid.push(a); reasons.push(`@${a} is detached`); continue; }
+ if (!p.paneId) { invalid.push(a); reasons.push(`@${a} has no pane`); continue; }
+ }
+ if (invalid.length > 0) {
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Pipe error: invalid assignees (${reasons.join('; ')}). All assignees must be connected LLM participants with a live pane.`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ // Write start message to log with pipe metadata
+ const pipeId = pipeReducer.generatePipeId();
+ const resolved = { ...parsed, assignees: resolvedAssignees };
+ const desc = pipeReducer.getStartDescription(resolved);
+
+ // Create pipe in the isolated stage store (with timeout config)
+ pipeStore.createPipe(pipeId, parsed.mode, resolvedAssignees, parsed.prompt, projectId, {
+ stageTimeoutMs: parsed.stageTimeoutMs,
+ timeoutPolicy: parsed.timeoutPolicy,
+ });
+ provenance.recordProvenance(projectId, { pipeId, event: 'created', actor: 'user', actorKind: 'user', metadata: { mode: parsed.mode, assignees: resolvedAssignees } });
+
+ // Ensure the pipe watchdog is running
+ startPipeWatchdog();
+
+ const startMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${pipeId} Pipe started (${parsed.mode}): ${desc}`,
+ type: 'system',
+ pipe: {
+ pipeId,
+ mode: parsed.mode,
+ role: 'start',
+ assignees: resolvedAssignees,
+ prompt: parsed.prompt,
+ },
+ }, projectId);
+ emitToProject('chat:message', startMsg, projectId);
+ emitPipeEvent({
+ type: 'start', pipeId, mode: parsed.mode,
+ assignees: resolvedAssignees,
+ prompt: parsed.prompt,
+ stageTimeoutMs: parsed.stageTimeoutMs ?? pipeStore.DEFAULT_STAGE_TIMEOUT_MS,
+ timeoutPolicy: parsed.timeoutPolicy ?? 'fail',
+ }, projectId);
+
+ // Run reducer to emit initial handoff/fan-out
+ await runPipeReducer(pipeId, projectId);
+
+ return userMsg;
+}
+
+/** Detect if an LLM message is a response to an active pipe.
+ * Uses #pipe-{id} in the body as primary discriminator; falls back to
+ * most-recently-prompted pipe if no explicit tag is present. */
+function detectPipeResponse(from: string, body: string, projectId: string | null): PipeMessageMeta | undefined {
+ // ── Store-backed detection (primary) ──
+ const explicitMatch = body.match(/#pipe-([a-f0-9]+)/);
+ if (explicitMatch) {
+ const storedPipe = pipeStore.getPipe(explicitMatch[1], projectId);
+ if (storedPipe && storedPipe.status === 'running') {
+ const state = pipeReducer.buildStateFromStore(storedPipe);
+ const meta = pipeReducer.matchResponse(state, from);
+ if (meta) return meta;
+ }
+ }
+
+ const senderPipeIds = pipeStore.getActivePipesForParticipant(from, projectId);
+ for (const pipeId of senderPipeIds) {
+ const storedPipe = pipeStore.getPipe(pipeId, projectId);
+ if (!storedPipe || storedPipe.status !== 'running') continue;
+ const state = pipeReducer.buildStateFromStore(storedPipe);
+ const meta = pipeReducer.matchResponse(state, from);
+ if (meta) return meta;
+ }
+
+ // ── Log-backed fallback (recovery / legacy pipes not in store) ──
+ const messages = readMessages({ limit: 10000 }, projectId);
+ const pipeIds = new Set();
+ for (const msg of messages) {
+ if (msg.pipe?.pipeId) pipeIds.add(msg.pipe.pipeId);
+ }
+ if (pipeIds.size === 0) return undefined;
+
+ if (explicitMatch && pipeIds.has(explicitMatch[1])) {
+ const state = pipeReducer.derivePipeState(messages, explicitMatch[1]);
+ if (state && state.status === 'running') {
+ const meta = pipeReducer.matchResponse(state, from);
+ if (meta) return meta;
+ }
+ }
+
+ // Last resort: find the most recently prompted pipe for this sender in the log
+ let lastPromptedPipeId: string | undefined;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (!msg.pipe) continue;
+ const role = msg.pipe.role;
+ if (
+ msg.pipe.targetAssignee === from &&
+ (role === 'handoff' || role === 'fan-out-request' || role === 'synth-request')
+ ) {
+ lastPromptedPipeId = msg.pipe.pipeId;
+ break;
+ }
+ }
+
+ if (lastPromptedPipeId) {
+ const state = pipeReducer.derivePipeState(messages, lastPromptedPipeId);
+ if (state && state.status === 'running') {
+ const meta = pipeReducer.matchResponse(state, from);
+ if (meta) return meta;
+ }
+ }
+
+ return undefined;
+}
+
+/** Run the pipe reducer: derive state from pipe store, compute next actions, execute them.
+ * Pipe instructions and intermediate outputs NEVER enter chat history.
+ * Only the final result is appended as a public chat message. */
+async function runPipeReducer(pipeId: string, projectId: string | null): Promise {
+ const storedPipe = pipeStore.getPipe(pipeId, projectId);
+ if (!storedPipe || storedPipe.status !== 'running') return;
+
+ // Build state entirely from pipe store — no log scanning
+ const state = pipeReducer.buildStateFromStore(storedPipe);
+
+ // Check for completion — broadcast final result as a public chat message
+ if (state.hasFinal) {
+ clearAllDeadlinesForPipe(pipeId);
+ pipeDelivery.cancelAllDeliveries(pipeId, projectId);
+ materializer.cancelPipeAssignments(pipeId, projectId);
+ pipeStore.markPipeStatus(pipeId, 'completed', projectId);
+ provenance.recordProvenance(projectId, { pipeId, event: 'completed', actor: 'system', actorKind: 'system' });
+
+ // Read the final output from pipe state and persist it for the user.
+ // Final output is user-only: persisted in chat history and emitted to
+ // dashboard via Socket.IO, but NOT PTY-delivered to LLM participants.
+ // This prevents long output from cluttering LLM terminals.
+ const finalContent = readFinalOutput(pipeId, projectId);
+ if (finalContent) {
+ const resultMsg = appendMessage({
+ from: finalContent.from, to: 'user',
+ body: ensurePipeAnchor(finalContent.body, pipeId),
+ type: 'message',
+ pipe: { pipeId, mode: state.mode, role: 'final' },
+ }, projectId);
+ emitToProject('chat:message', resultMsg, projectId);
+ // No PTY delivery — user sees it on dashboard only.
+ }
+
+ emitPipeEvent({ type: 'complete', pipeId }, projectId);
+
+ // Check if this pipe is a brainstorm child — advance brainstorm state
+ await advanceBrainstormOnChildComplete(pipeId, projectId);
+ return;
+ }
+
+ const actions = pipeReducer.computeNextActions(state);
+ for (const action of actions) {
+ // Grant lease to target assignee in the store
+ const leaseResult = pipeStore.grantLease(pipeId, action.targetAssignee, projectId);
+ if (!leaseResult.ok) {
+ pipeStore.addPendingPipe(action.targetAssignee, projectId, pipeId);
+ // Emit queued diagnostic to dashboard UI only — NOT to chat history
+ emitPipeEvent({
+ type: 'queued',
+ pipeId,
+ assignee: action.targetAssignee,
+ reason: leaseResult.error,
+ }, projectId);
+ continue;
+ }
+
+ // Start stage deadline timer for this lease
+ startStageDeadline(pipeId, action.targetAssignee, projectId, storedPipe.stageTimeoutMs, storedPipe.timeoutPolicy);
+ provenance.recordProvenance(projectId, { pipeId, event: 'stage-granted', actor: 'system', actorKind: 'system', stage: action.type === 'handoff' ? action.stage : undefined, role: action.type, metadata: { assignee: action.targetAssignee } });
+
+ // Track emission in pipe store (replaces appendMessage to chat history)
+ pipeStore.markEmitted(pipeId, action.type, action.type === 'handoff' ? action.stage : action.targetAssignee, projectId);
+
+ // Materialize assignment + payload for lifecycle tracking
+ const materialized = materializer.materializeAssignment(pipeId, state.mode, action, projectId);
+
+ // Transport-layer: create delivery record for re-notify tracking
+ pipeDelivery.createDelivery(
+ pipeId, action.targetAssignee, action.type, action.body, projectId, action.stage,
+ );
+
+ // Format compact notification — PTY gets a pointer, not the full payload
+ const notification = pipeDelivery.formatCompactNotification(
+ pipeId,
+ state.mode,
+ action.type,
+ action.targetAssignee,
+ state.assignees.length,
+ action.stage,
+ );
+
+ // Construct compact delivery message for PTY injection — NOT stored in chat history.
+ const deliveryMsg: import('../types.js').ChatMessage = {
+ id: `pipe-${pipeId}-${action.type}-${action.targetAssignee}`,
+ ts: new Date().toISOString(),
+ from: 'system',
+ to: action.targetAssignee,
+ body: notification.body,
+ type: 'system',
+ pipe: action.pipe,
+ };
+
+ // Emit to dashboard UI as a pipe event (not chat:message — stays out of chat rendering)
+ emitPipeEvent({
+ type: 'instruction',
+ pipeId,
+ actionType: action.type,
+ assignee: action.targetAssignee,
+ stage: action.stage,
+ }, projectId);
+
+ // PTY deliver only to the target assignee
+ const target = getParticipantExact(action.targetAssignee, projectId);
+ if (target?.paneId && !target.detached) {
+ await deliverToPty(action.targetAssignee, projectId, deliveryMsg);
+ // Transition assignment lifecycle: assigned → notified (after successful PTY delivery)
+ if (materialized) {
+ materializer.transitionAssignmentStatus(materialized.assignmentId, 'notified', projectId);
+ }
+ // Transport-layer: record notification attempt for re-notify tracking
+ pipeDelivery.recordNotification(pipeId, action.targetAssignee, projectId);
+ pipeDelivery.startRenotifyTimer(pipeId, action.targetAssignee, projectId, handleRenotify);
+ }
+ }
+}
+
+/** Re-notify: re-delivers compact notification to a tardy assignee. */
+function handleRenotify(pipeId: string, assignee: string, projectId: string | null): void {
+ const record = pipeDelivery.getDelivery(pipeId, assignee, projectId);
+ if (!record || record.state !== 'notified') return;
+ const pipe = pipeStore.getPipe(pipeId, projectId);
+ if (!pipe || pipe.status !== 'running') return;
+ const target = getParticipantExact(assignee, projectId);
+ if (!target?.paneId || target.detached) return;
+ const notification = pipeDelivery.formatCompactNotification(
+ pipeId,
+ pipe.mode,
+ record.role as 'handoff' | 'fan-out-request' | 'synth-request',
+ assignee,
+ pipe.assignees.length,
+ record.stage,
+ );
+ const renotifyMsg: import('../types.js').ChatMessage = {
+ id: `pipe-${pipeId}-renotify-${assignee}-${record.notifyAttempts}`,
+ ts: new Date().toISOString(), from: 'system', to: assignee,
+ body: notification.body, type: 'system', pipe: notification.pipe,
+ };
+ deliverToPty(assignee, projectId, renotifyMsg)
+ .then(() => {
+ pipeDelivery.recordNotification(pipeId, assignee, projectId);
+ pipeDelivery.startRenotifyTimer(pipeId, assignee, projectId, handleRenotify);
+ })
+ .catch(err => console.error(`[pipe] re-notify failed for ${assignee}:`, err));
+}
+
+/** Read the final output content from pipe state. */
+function readFinalOutput(pipeId: string, projectId: string | null): { from: string; body: string } | null {
+ const pipe = pipeStore.getPipe(pipeId, projectId);
+ if (!pipe) return null;
+ for (const [, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.role === 'final' && slot.status === 'submitted' && slot.content) {
+ return { from: slot.assignee, body: slot.content };
+ }
+ }
+ }
+ return null;
+}
+
+/** Drain pending pipe queues for a list of assignees whose leases were just released.
+ * Re-runs the reducer for each blocked pipe so handoffs can be retried. */
+function drainPendingPipes(assignees: string[], projectId: string | null): void {
+ for (const assignee of assignees) {
+ const pendingPipeIds = pipeStore.popPendingPipes(assignee, projectId);
+ for (const pendingPipeId of pendingPipeIds) {
+ runPipeReducer(pendingPipeId, projectId)
+ .catch(err => console.error('[pipe] pending reducer failed:', err));
+ }
+ }
+}
+
+/** Fail-fast: cancel running pipes when a participant becomes unavailable. */
+function failPipesForParticipant(
+ name: string,
+ projectId: string | null,
+ reason: 'left' | 'detached' | 'pane-closed',
+): void {
+ // Use the active pipe index for O(1) lookup instead of scanning full history
+ const activePipeIds = pipeStore.getActivePipesForParticipant(name, projectId);
+
+ for (const pipeId of activePipeIds) {
+ // Idempotency: check pipe is still running in the store
+ const storedPipe = pipeStore.getPipe(pipeId, projectId);
+ if (!storedPipe || storedPipe.status !== 'running') continue;
+
+ // Clear all deadline timers and delivery tracking for this pipe
+ clearAllDeadlinesForPipe(pipeId);
+ pipeDelivery.cancelAllDeliveries(pipeId, projectId);
+ materializer.cancelPipeAssignments(pipeId, projectId);
+
+ // Update store — releases leases for this pipe's assignees
+ const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'failed', projectId);
+
+ provenance.recordProvenance(projectId, { pipeId, event: 'failed', actor: 'system', actorKind: 'system', metadata: { reason, unavailableParticipant: name } });
+
+ // Post failure to chat history (public lifecycle event)
+ const failMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${pipeId} Pipe stopped: @${name} became unavailable (${reason}).`,
+ type: 'system',
+ pipe: {
+ pipeId,
+ mode: storedPipe.mode,
+ role: 'failed',
+ reason,
+ },
+ }, projectId);
+ emitToProject('chat:message', failMsg, projectId);
+ emitPipeEvent({ type: 'failed', pipeId }, projectId);
+
+ // Drain pending queues for released assignees — unblock any pipes waiting for their lease
+ drainPendingPipes(releasedAssignees, projectId);
+ }
+}
+
+/** Cancel a running pipe by user request. */
+export async function cancelPipeRun(pipeId: string, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const pipe = pipeStore.getPipe(pipeId, pid);
+ if (!pipe || pipe.status !== 'running') return false;
+
+ // Clear all deadline timers and delivery tracking for this pipe
+ clearAllDeadlinesForPipe(pipeId);
+ pipeDelivery.cancelAllDeliveries(pipeId, pid);
+ materializer.cancelPipeAssignments(pipeId, pid);
+
+ // Update store — releases leases for this pipe's assignees
+ const releasedAssignees = pipeStore.markPipeStatus(pipeId, 'cancelled', pid);
+ provenance.recordProvenance(pid, { pipeId, event: 'cancelled', actor: 'user', actorKind: 'user' });
+
+ const cancelMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${pipeId} Pipe cancelled.`,
+ type: 'system',
+ pipe: {
+ pipeId,
+ mode: pipe.mode,
+ role: 'cancelled',
+ reason: 'cancelled-by-user',
+ },
+ }, pid);
+ emitToProject('chat:message', cancelMsg, pid);
+ emitPipeEvent({ type: 'cancel', pipeId }, pid);
+
+ // Drain pending queues for released assignees — unblock any pipes waiting for their lease
+ drainPendingPipes(releasedAssignees, pid);
+
+ return true;
+}
+
+/** Submit a stage artifact via the dedicated pipe_submit path.
+ * Validates lease, stores content, posts a display message, and advances the pipeline. */
+export async function submitPipeStage(
+ pipeId: string,
+ from: string,
+ content: string,
+ projectId: string | null,
+): Promise<{ ok: boolean; error?: string; code?: string; message?: ChatMessage; myWorkComplete?: boolean; pendingStages?: number }> {
+ // Validate and store in the pipe stage store
+ const result = pipeStore.submitStage(pipeId, from, content, projectId, true);
+ if (!result.ok) return { ok: false, error: result.error, code: result.code };
+
+ // Clear the stage deadline and delivery tracking — submit was successful
+ pipeDelivery.recordSubmission(pipeId, from, projectId);
+ // Complete the active assignment for this participant on this pipe
+ const activeAssignments = materializer.getActiveAssignmentsForParticipant(from, pipeId, projectId);
+ for (const a of activeAssignments) {
+ materializer.completeAssignment(a.assignmentId, projectId);
+ }
+ clearStageDeadline(pipeId, from);
+ provenance.recordProvenance(projectId, { pipeId, event: 'stage-submitted', actor: from, actorKind: getParticipant(from, projectId)?.kind ?? 'llm', stage: result.slot?.stage, role: result.slot?.role });
+
+ // Determine pipe role for the chat message metadata
+ const storedPipe = pipeStore.getPipe(pipeId, projectId);
+ if (!storedPipe) return { ok: false, error: 'Pipe not found after submit' };
+
+ const slot = result.slot;
+ let role: PipeMessageMeta['role'] = 'stage-output';
+ if (slot?.role === 'final') role = 'final';
+ else if (slot?.role === 'fan-out') role = 'fan-out';
+
+ const pipeMeta: PipeMessageMeta = {
+ pipeId,
+ mode: storedPipe.mode,
+ role,
+ stage: slot?.stage,
+ };
+
+ // Emit non-final stage submissions as pipe-step events — NOT as chat:message.
+ // The completion handler is the sole emitter of the public final result via chat:message.
+ const body = ensurePipeAnchor(content, pipeId);
+ const msg: import('../types.js').ChatMessage = {
+ id: `pipe-${pipeId}-${role}-${from}`,
+ ts: new Date().toISOString(),
+ from,
+ to: null,
+ body,
+ type: 'message',
+ pipe: pipeMeta,
+ };
+ if (role !== 'final') {
+ emitPipeEvent({ type: 'stage-output', pipeId, from, role, stage: slot?.stage, content: body }, projectId);
+ }
+
+ // Run the reducer to advance the pipeline
+ await runPipeReducer(pipeId, projectId);
+
+ // Lease was released by submitStage — drain pending queues to unblock
+ // any pipes that were waiting for this participant's lease.
+ drainPendingPipes([from], projectId);
+
+ // Check if the submitter has remaining unsubmitted slots in this pipe
+ const updatedPipe = pipeStore.getPipe(pipeId, projectId);
+ const assigneeSlots = updatedPipe?.slots.get(from) ?? [];
+ const pendingSlots = assigneeSlots.filter(s => s.status !== 'submitted');
+ const myWorkComplete = pendingSlots.length === 0;
+ const pendingStages = pendingSlots.length;
+
+ return { ok: true, message: msg, myWorkComplete, pendingStages };
+}
+
+/** Get pipe status from the store. */
+export function getPipeStoreStatus(pipeId: string, projectId?: string | null) {
+ return pipeStore.getPipeStatus(pipeId, resolveProjectId(projectId));
+}
+
+/** Get active pipes for a project (from pipe store). */
+export function getActivePipes(projectId?: string | null): Array<{ pipeId: string; mode: string; status: string }> {
+ const pid = resolveProjectId(projectId);
+ return pipeStore.listActivePipes(pid).map(p => ({ pipeId: p.pipeId, mode: p.mode, status: p.status }));
+}
+
+/** Get a specific pipe's state (from pipe store). */
+export function getPipeRun(pipeId: string, projectId?: string | null): { pipeId: string; mode: string; status: string; projectId: string | null } | undefined {
+ const pid = resolveProjectId(projectId);
+ const pipe = pipeStore.getPipe(pipeId, pid);
+ if (!pipe) return undefined;
+ return { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, projectId: pid };
+}
+
+// ── Pipe assignment queries (caller-scoped) ──────────────────────────────────
+
+/** List all assignments for a participant. */
+export function listAssignments(callerName: string, projectId?: string | null) {
+ const pid = resolveProjectId(projectId);
+ return assignmentQueries.getAssignmentsForParticipant(callerName, pid);
+}
+
+/** Get assignment details for a participant on a specific pipe. */
+export function getAssignment(pipeId: string, callerName: string, projectId?: string | null) {
+ const pid = resolveProjectId(projectId);
+ return assignmentQueries.getAssignmentForPipe(pipeId, callerName, pid);
+}
+
+// ── Pipe output read (caller-scoped) ──────────────────────────────────────────
+
+export interface PipeReadOutputResult {
+ pipeId: string;
+ mode: string;
+ stagePayload?: string | null;
+ previousOutput?: { stage: number; from: string; content: string } | null;
+ fanOutOutputs?: Array<{ from: string; content: string }>;
+}
+
+/** Read the pipe output that the caller is entitled to right now.
+ * Linear pipes: returns previous stage output for the current downstream assignee.
+ * Merge pipes: returns fan-out outputs for the synthesizer after synth-request. */
+export function readPipeOutput(
+ pipeId: string,
+ callerName: string,
+ projectId?: string | null,
+): { ok: true; data: PipeReadOutputResult } | { ok: false; status: number; error: string } {
+ const pid = resolveProjectId(projectId);
+ const pipe = pipeStore.getPipe(pipeId, pid);
+ if (!pipe) return { ok: false, status: 404, error: `Pipe #${pipeId} not found` };
+ if (pipe.status !== 'running') {
+ return { ok: false, status: 409, error: `Pipe #${pipeId} is ${pipe.status} — output reads are only allowed while the pipe is running` };
+ }
+
+ const assigneeIndex = pipe.assignees.indexOf(callerName);
+ if (assigneeIndex === -1) {
+ return { ok: false, status: 403, error: `${callerName} is not an assignee of pipe #${pipeId}` };
+ }
+
+ // Lease-aware read guard: reject reads from assignees with expired leases.
+ // Must run BEFORE recordFetch so rejected reads don't suppress re-notify.
+ const callerLease = pipeStore.getActiveLease(callerName, pid);
+ if (callerLease?.pipeId === pipeId && pipeStore.isLeaseExpired(callerLease)) {
+ return { ok: false, status: 403, error: `Lease for ${callerName} on pipe #${pipeId} has expired (deadline: ${callerLease.deadline}). Output read rejected.` };
+ }
+
+ // Record fetch acknowledgment — only after authorization succeeds
+ pipeDelivery.recordFetch(pipeId, callerName, pid);
+
+ // Transition assignment lifecycle: notified → payload_fetched
+ const activeAssignments = materializer.getActiveAssignmentsForParticipant(callerName, pipeId, pid);
+ for (const a of activeAssignments) {
+ if (a.status === 'notified' || a.status === 'acknowledged') {
+ materializer.transitionAssignmentStatus(a.assignmentId, a.status === 'notified' ? 'acknowledged' : 'payload_fetched', pid);
+ // If we went notified→acknowledged, also advance to payload_fetched
+ if (a.status === 'notified') {
+ materializer.transitionAssignmentStatus(a.assignmentId, 'payload_fetched', pid);
+ }
+ }
+ }
+
+ // Read the authoritative assignment payload for this caller on this pipe.
+ const currentAssignment = activeAssignments.find(a => a.assignee === callerName) ?? null;
+ const stagePayload = currentAssignment
+ ? (payloadStore.getPayload(currentAssignment.payloadId, pid)?.content ?? null)
+ : null;
+
+ if (pipe.mode === 'linear') {
+ const callerStage = assigneeIndex + 1;
+ if (callerStage === 1) {
+ if (!stagePayload) {
+ return { ok: false, status: 409, error: 'Stage 1 has no previous input to read' };
+ }
+ return { ok: true, data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, previousOutput: null } };
+ }
+ if (!pipe.emittedHandoffs.has(callerStage)) {
+ return { ok: false, status: 409, error: `Handoff for stage ${callerStage} has not been emitted yet` };
+ }
+ const prevStage = callerStage - 1;
+ const output = pipeStore.getStageOutput(pipeId, prevStage, pid);
+ if (!output) {
+ return { ok: false, status: 409, error: `Stage ${prevStage} output not yet submitted` };
+ }
+ return {
+ ok: true,
+ data: {
+ pipeId: pipe.pipeId,
+ mode: pipe.mode,
+ stagePayload,
+ previousOutput: { stage: prevStage, from: output.from, content: output.body },
+ },
+ };
+ }
+
+ // merge / merge-all / explain / summarize
+ if (currentAssignment?.role === 'fan-out') {
+ if (!stagePayload) {
+ return { ok: false, status: 409, error: 'No stage input available for your fan-out assignment' };
+ }
+ return {
+ ok: true,
+ data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, previousOutput: null },
+ };
+ }
+
+ const synthesizer = pipe.assignees[pipe.assignees.length - 1];
+ if (callerName !== synthesizer) {
+ return { ok: false, status: 403, error: `Only the synthesizer (@${synthesizer}) can read fan-out outputs` };
+ }
+ if (!pipe.emittedSynthRequest) {
+ return { ok: false, status: 409, error: 'Synth request has not been emitted yet' };
+ }
+ const isMergeAll = pipe.mode === 'merge-all' || pipe.mode === 'explain' || pipe.mode === 'summarize';
+ const outputs = pipeStore.getFanOutOutputs(pipeId, pid);
+ const fanOutOutputs: Array<{ from: string; content: string }> = [];
+ for (const [assignee, content] of outputs) {
+ if (isMergeAll && assignee === synthesizer) continue;
+ fanOutOutputs.push({ from: assignee, content });
+ }
+ return {
+ ok: true,
+ data: { pipeId: pipe.pipeId, mode: pipe.mode, stagePayload, fanOutOutputs },
+ };
+}
+
+// ── Brainstorm command handling ───────────────────────────────────────────────
+
+async function handleBrainstormCommand(body: string, projectId: string | null): Promise {
+ const parsed = parseBrainstormCommand(body, (name) => getParticipantExact(name, projectId) != null);
+ if ('error' in parsed) {
+ const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId);
+ emitToProject('chat:message', userMsg, projectId);
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Brainstorm error: ${parsed.error}`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ const userMsg = appendMessage({ from: 'user', to: null, body, type: 'message' }, projectId);
+ emitToProject('chat:message', userMsg, projectId);
+
+ // Resolve assignees (default to all active LLMs if none specified)
+ const resolvedAssignees = parsed.assignees.length > 0
+ ? parsed.assignees
+ : listDefaultPipeAssignees(projectId).map(p => p.name);
+
+ if (resolvedAssignees.length < 2) {
+ const detail = parsed.assignees.length === 0
+ ? ' No eligible default LLM assignees were available.'
+ : '';
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Brainstorm error: at least 2 LLM participants are required.${detail}`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ // Validate assignees are connected LLMs
+ const invalid: string[] = [];
+ const reasons: string[] = [];
+ for (const a of resolvedAssignees) {
+ const p = getParticipantExact(a, projectId);
+ if (!p) { invalid.push(a); reasons.push(`@${a} not found`); continue; }
+ if (p.kind !== 'llm') { invalid.push(a); reasons.push(`@${a} is not an LLM`); continue; }
+ if (p.detached) { invalid.push(a); reasons.push(`@${a} is detached`); continue; }
+ if (!p.paneId) { invalid.push(a); reasons.push(`@${a} has no pane`); continue; }
+ }
+ if (invalid.length > 0) {
+ const errorMsg = appendMessage({
+ from: 'system', to: null,
+ body: `Brainstorm error: invalid assignees (${reasons.join('; ')}).`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', errorMsg, projectId);
+ return userMsg;
+ }
+
+ // Create brainstorm record
+ const brainstormId = pipeReducer.generatePipeId();
+ brainstormStore.createBrainstorm(brainstormId, resolvedAssignees, parsed.prompt, projectId);
+
+ const assigneeList = resolvedAssignees.map(a => `@${a}`).join(', ');
+ const startMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${brainstormId} Brainstorm started: ${assigneeList}\nTopic: ${parsed.prompt}\nPhase: Ideas`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', startMsg, projectId);
+
+ // Launch the first idea round (merge-all child pipe)
+ await launchBrainstormIdeaRound(brainstormId, projectId);
+
+ return userMsg;
+}
+
+/** Launch (or re-launch on retry) a merge-all child pipe for the brainstorm idea phase. */
+async function launchBrainstormIdeaRound(brainstormId: string, projectId: string | null): Promise {
+ const record = brainstormStore.getBrainstorm(brainstormId, projectId);
+ if (!record) return;
+
+ const prompt = record.latestUserNote
+ ? `${record.prompt}\n\nUser note: ${record.latestUserNote}`
+ : record.prompt;
+
+ const childPipeId = pipeReducer.generatePipeId();
+ pipeStore.createPipe(childPipeId, 'merge-all', record.assignees, prompt, projectId);
+ brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId);
+ brainstormStore.updateBrainstorm(brainstormId, projectId, {
+ activeChildPipeId: childPipeId,
+ phase: 'ideas',
+ ideaIterations: record.ideaIterations + 1,
+ });
+
+ const desc = pipeReducer.getStartDescription({ mode: 'merge-all', assignees: record.assignees, prompt });
+ const pipeStartMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${childPipeId} Pipe started (merge-all): ${desc}`,
+ type: 'system',
+ pipe: { pipeId: childPipeId, mode: 'merge-all' as const, role: 'start' as const, assignees: record.assignees, prompt },
+ }, projectId);
+ emitToProject('chat:message', pipeStartMsg, projectId);
+ emitPipeEvent({
+ type: 'start', pipeId: childPipeId, mode: 'merge-all',
+ assignees: record.assignees, prompt,
+ stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS,
+ timeoutPolicy: 'fail',
+ }, projectId);
+
+ await runPipeReducer(childPipeId, projectId);
+}
+
+/** Called when a child pipe completes — advances the brainstorm phase if applicable. */
+async function advanceBrainstormOnChildComplete(childPipeId: string, projectId: string | null): Promise {
+ const record = brainstormStore.findBrainstormByChildPipe(childPipeId, projectId);
+ if (!record || record.activeChildPipeId !== childPipeId) return;
+
+ if (record.phase === 'ideas') {
+ const finalOutput = readFinalOutput(childPipeId, projectId);
+ brainstormStore.updateBrainstorm(record.id, projectId, {
+ phase: 'ideas_review',
+ activeChildPipeId: null,
+ candidateIdea: finalOutput?.body ?? null,
+ });
+
+ const reviewMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${record.id} Ideas phase complete (iteration ${record.ideaIterations}).\nReview the merged idea above and choose:\n• Accept — advance to detail phase\n• Retry — rerun idea generation\n• Retry with note — add guidance and rerun`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', reviewMsg, projectId);
+ return;
+ }
+
+ if (record.phase === 'details') {
+ const finalOutput = readFinalOutput(childPipeId, projectId);
+ brainstormStore.updateBrainstorm(record.id, projectId, {
+ phase: 'details_review',
+ activeChildPipeId: null,
+ candidateDraft: finalOutput?.body ?? null,
+ });
+
+ const reviewMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${record.id} Detail pass complete (iteration ${record.detailIterations}).\nReview the detailed draft above and choose:\n• Finalize — accept draft and generate final output\n• Adjust — retry detail pass with guidance\n• Back to Ideas — return to idea phase`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', reviewMsg, projectId);
+ return;
+ }
+
+ if (record.phase === 'finalizing') {
+ brainstormStore.updateBrainstorm(record.id, projectId, {
+ phase: 'complete',
+ activeChildPipeId: null,
+ });
+
+ const completeMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${record.id} Brainstorm complete.`,
+ type: 'system',
+ }, projectId);
+ emitToProject('chat:message', completeMsg, projectId);
+ }
+}
+
+/** Re-launch the idea round with an optional user note (called by approve/retry endpoints). */
+export async function brainstormRetryIdeas(brainstormId: string, userNote: string | null, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const record = brainstormStore.getBrainstorm(brainstormId, pid);
+ if (!record || record.phase !== 'ideas_review') return false;
+
+ brainstormStore.updateBrainstorm(brainstormId, pid, { latestUserNote: userNote });
+ await launchBrainstormIdeaRound(brainstormId, pid);
+ return true;
+}
+
+/** Accept the current idea and launch detail phase (linear child pipe). */
+export async function brainstormAcceptIdea(brainstormId: string, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const record = brainstormStore.getBrainstorm(brainstormId, pid);
+ if (!record || record.phase !== 'ideas_review') return false;
+
+ brainstormStore.updateBrainstorm(brainstormId, pid, {
+ acceptedIdea: record.candidateIdea,
+ candidateIdea: null,
+ latestUserNote: null,
+ });
+
+ const acceptMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${brainstormId} Idea accepted. Advancing to detail phase.`,
+ type: 'system',
+ }, pid);
+ emitToProject('chat:message', acceptMsg, pid);
+
+ await launchBrainstormDetailRound(brainstormId, pid);
+ return true;
+}
+
+/** Launch (or re-launch on adjust) a linear child pipe for the brainstorm detail phase. */
+async function launchBrainstormDetailRound(brainstormId: string, projectId: string | null): Promise {
+ const record = brainstormStore.getBrainstorm(brainstormId, projectId);
+ if (!record) return;
+
+ let prompt = `Brainstorm detail phase — deepen the following accepted idea:\n\n${record.acceptedIdea}\n\nAdd implementation details, architecture considerations, trade-offs, and concrete next steps.`;
+ if (record.latestUserNote) {
+ prompt += `\n\nUser note: ${record.latestUserNote}`;
+ }
+
+ const childPipeId = pipeReducer.generatePipeId();
+ pipeStore.createPipe(childPipeId, 'linear', record.assignees, prompt, projectId);
+ brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId);
+ brainstormStore.updateBrainstorm(brainstormId, projectId, {
+ activeChildPipeId: childPipeId,
+ phase: 'details',
+ detailIterations: record.detailIterations + 1,
+ });
+
+ const desc = pipeReducer.getStartDescription({ mode: 'linear', assignees: record.assignees, prompt });
+ const pipeStartMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${childPipeId} Pipe started (linear): ${desc}`,
+ type: 'system',
+ pipe: { pipeId: childPipeId, mode: 'linear' as const, role: 'start' as const, assignees: record.assignees, prompt },
+ }, projectId);
+ emitToProject('chat:message', pipeStartMsg, projectId);
+ emitPipeEvent({
+ type: 'start', pipeId: childPipeId, mode: 'linear',
+ assignees: record.assignees, prompt,
+ stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS,
+ timeoutPolicy: 'fail',
+ }, projectId);
+
+ await runPipeReducer(childPipeId, projectId);
+}
+
+/** Launch the finalize pass — single LLM produces the final structured output. */
+async function launchBrainstormFinalizeRound(brainstormId: string, projectId: string | null): Promise {
+ const record = brainstormStore.getBrainstorm(brainstormId, projectId);
+ if (!record) return;
+
+ const prompt = `Brainstorm finalize — produce the final comprehensive document.\n\nAccepted Idea:\n${record.acceptedIdea}\n\nAccepted Detail Draft:\n${record.acceptedDraft}\n\nCreate a complete, structured output covering: concept, architecture, trade-offs, decisions, and next steps.`;
+
+ // Use a single assignee (first in list) for the final pass
+ const finalAssignees = [record.assignees[0]];
+ const childPipeId = pipeReducer.generatePipeId();
+ pipeStore.createPipe(childPipeId, 'linear', finalAssignees, prompt, projectId);
+ brainstormStore.linkChildPipe(brainstormId, childPipeId, projectId);
+ brainstormStore.updateBrainstorm(brainstormId, projectId, {
+ activeChildPipeId: childPipeId,
+ phase: 'finalizing',
+ });
+
+ const desc = pipeReducer.getStartDescription({ mode: 'linear', assignees: finalAssignees, prompt });
+ const pipeStartMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#pipe-${childPipeId} Pipe started (linear): ${desc}`,
+ type: 'system',
+ pipe: { pipeId: childPipeId, mode: 'linear' as const, role: 'start' as const, assignees: finalAssignees, prompt },
+ }, projectId);
+ emitToProject('chat:message', pipeStartMsg, projectId);
+ emitPipeEvent({
+ type: 'start', pipeId: childPipeId, mode: 'linear',
+ assignees: finalAssignees, prompt,
+ stageTimeoutMs: pipeStore.DEFAULT_STAGE_TIMEOUT_MS,
+ timeoutPolicy: 'fail',
+ }, projectId);
+
+ await runPipeReducer(childPipeId, projectId);
+}
+
+/** Adjust and retry the current detail pass with a user note. */
+export async function brainstormAdjustDetails(brainstormId: string, userNote: string | null, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const record = brainstormStore.getBrainstorm(brainstormId, pid);
+ if (!record || record.phase !== 'details_review') return false;
+
+ brainstormStore.updateBrainstorm(brainstormId, pid, { latestUserNote: userNote, candidateDraft: null });
+ await launchBrainstormDetailRound(brainstormId, pid);
+ return true;
+}
+
+/** Accept the current detail draft and launch the finalize phase. */
+export async function brainstormFinalize(brainstormId: string, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const record = brainstormStore.getBrainstorm(brainstormId, pid);
+ if (!record || record.phase !== 'details_review') return false;
+
+ brainstormStore.updateBrainstorm(brainstormId, pid, {
+ acceptedDraft: record.candidateDraft,
+ candidateDraft: null,
+ });
+
+ const acceptMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${brainstormId} Details accepted. Generating final output.`,
+ type: 'system',
+ }, pid);
+ emitToProject('chat:message', acceptMsg, pid);
+
+ await launchBrainstormFinalizeRound(brainstormId, pid);
+ return true;
+}
+
+/** Go back to ideas phase from detail review. */
+export async function brainstormBackToIdeas(brainstormId: string, projectId?: string | null): Promise {
+ const pid = resolveProjectId(projectId);
+ const record = brainstormStore.getBrainstorm(brainstormId, pid);
+ if (!record || record.phase !== 'details_review') return false;
+
+ brainstormStore.updateBrainstorm(brainstormId, pid, {
+ phase: 'ideas_review',
+ candidateDraft: null,
+ acceptedDraft: null,
+ latestUserNote: null,
+ });
+
+ const backMsg = appendMessage({
+ from: 'system', to: null,
+ body: `#brainstorm-${brainstormId} Returning to ideas phase. Review the idea and choose: Accept, Retry, or Retry with note.`,
+ type: 'system',
+ }, pid);
+ emitToProject('chat:message', backMsg, pid);
+ return true;
+}
+
+// ── Brainstorm accessors ─────────────────────────────────────────────────────
+
+export function getBrainstormRecord(id: string, projectId?: string | null) {
+ return brainstormStore.getBrainstorm(id, resolveProjectId(projectId));
+}
+
+export function getActiveBrainstorms(projectId?: string | null) {
+ return brainstormStore.listActiveBrainstorms(resolveProjectId(projectId));
+}
+
+// ── Pipe observability ──────────────────────────────────────────────────────
+
+export function getPipeTimingSummary(pipeId: string, projectId?: string | null) {
+ return pipeStore.getPipeTimingSummary(pipeId, resolveProjectId(projectId));
+}
+export function getRuntimeLeaseStatuses(projectId?: string | null) {
+ return pipeStore.getRuntimeLeaseStatuses(resolveProjectId(projectId));
+}
+export function getDeadLetterEntries(projectId?: string | null) {
+ return pipeStore.getDeadLetterEntries(resolveProjectId(projectId));
+}
+export function listAllPipes(projectId?: string | null) {
+ return pipeStore.listAllPipes(resolveProjectId(projectId));
+}
+export function getPipeProvenance(pipeId: string, projectId?: string | null) {
+ return provenance.getProvenanceForPipe(pipeId, resolveProjectId(projectId));
+}
+export function queryPipeProvenance(
+ projectId?: string | null,
+ filters?: { pipeId?: string; actor?: string; event?: string; since?: string },
+) {
+ return provenance.queryProvenance(resolveProjectId(projectId), filters as Parameters[1]);
+}
diff --git a/src/apps/chat/services/chat-rules.test.ts b/src/apps/chat/services/chat-rules.test.ts
new file mode 100644
index 0000000..a5048e1
--- /dev/null
+++ b/src/apps/chat/services/chat-rules.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { existsSync, readFileSync, rmSync } from 'fs';
+import { join } from 'path';
+
+const TEST_ROOT = join(process.cwd(), '.tmp', 'devglide-chat-rules-tests');
+
+vi.mock('../../../packages/paths.js', () => ({
+ projectDataDir: (projectId: string, sub: string) => join(TEST_ROOT, projectId, sub),
+}));
+
+const {
+ DEFAULT_RULES,
+ deleteProjectRules,
+ getDefaultRules,
+ getEffectiveRules,
+ hasProjectRules,
+ saveProjectRules,
+} = await import('./chat-rules.js');
+
+const TEST_PROJECT_ID = 'chat-rules-test-project';
+const TEST_CHAT_DIR = join(TEST_ROOT, TEST_PROJECT_ID, 'chat');
+const TEST_RULES_PATH = join(TEST_CHAT_DIR, 'rules.md');
+
+afterEach(() => {
+ rmSync(TEST_CHAT_DIR, { recursive: true, force: true });
+});
+
+describe('chat-rules', () => {
+ it('returns the hardcoded default rules when no project override exists', () => {
+ expect(getDefaultRules()).toBe(DEFAULT_RULES);
+ expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(DEFAULT_RULES);
+ expect(hasProjectRules(TEST_PROJECT_ID)).toBe(false);
+ expect(DEFAULT_RULES).toContain('Default: discussion only.');
+ expect(DEFAULT_RULES).toContain('Execution requires explicit assignment.');
+ expect(DEFAULT_RULES).toContain('Pipes use `pipe_submit` only.');
+ expect(DEFAULT_RULES).toContain('User-directed replies should start with `@user`.');
+ });
+
+ it('saves and resolves a project-specific override', () => {
+ const override = '## Project Rules\n\nOnly reply when asked.';
+
+ saveProjectRules(TEST_PROJECT_ID, override);
+
+ expect(hasProjectRules(TEST_PROJECT_ID)).toBe(true);
+ expect(existsSync(TEST_RULES_PATH)).toBe(true);
+ expect(readFileSync(TEST_RULES_PATH, 'utf8')).toBe(override);
+ expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(override);
+ });
+
+ it('deletes a project override and falls back to defaults', () => {
+ saveProjectRules(TEST_PROJECT_ID, 'temporary override');
+
+ expect(deleteProjectRules(TEST_PROJECT_ID)).toBe(true);
+ expect(hasProjectRules(TEST_PROJECT_ID)).toBe(false);
+ expect(getEffectiveRules(TEST_PROJECT_ID)).toBe(DEFAULT_RULES);
+ expect(deleteProjectRules(TEST_PROJECT_ID)).toBe(false);
+ });
+});
diff --git a/src/apps/chat/services/chat-rules.ts b/src/apps/chat/services/chat-rules.ts
new file mode 100644
index 0000000..c8dfdd4
--- /dev/null
+++ b/src/apps/chat/services/chat-rules.ts
@@ -0,0 +1,95 @@
+import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+import { getActiveProject } from '../../../project-context.js';
+import { projectDataDir } from '../../../packages/paths.js';
+
+const RULES_FILENAME = 'rules.md';
+
+/** Default rules of engagement - hardcoded, returned when no per-project override exists. */
+export const DEFAULT_RULES = `## Rules of Engagement
+
+1. **Default: discussion only.**
+ Every message is discussion by default. You may analyze, explain, recommend, and ask questions. Do not run commands, edit files, or make persistent changes unless explicitly assigned.
+
+2. **Execution requires explicit assignment.**
+ Execution is allowed only when the user addresses you by name and gives an action verb, for example: \`@yourname implement\`, \`@yourname fix\`, \`@yourname review\`, \`@yourname revert\`, or when you receive a pipe stage assignment.
+
+3. **No assignment = no action.**
+ These do **not** count as permission: agreement, consensus, another agent's suggestion, or your own initiative. If the message is ambiguous, treat it as discussion only.
+
+4. **Pipes use \`pipe_submit\` only.**
+ For pipe stages, submit with \`pipe_submit\`. \`chat_send\` does not submit pipe work.
+
+5. **All chat responses must use \`chat_send\`.**
+ Do not respond by outputting text in your own shell — other participants cannot see it. The chat room is the shared channel; your shell is private.
+
+6. **User-directed replies should start with \`@user\`.**
+ When replying to the human user in chat, begin the message with \`@user\` so the intended recipient is explicit to both the user and other LLM participants.
+
+7. **Respond selectively.**
+ Respond when you are \`@mentioned\`, or when the user sends an unaddressed message and you have new information. Stay silent when another agent is addressed, when you have nothing new to add, or when in doubt.
+
+8. **Assigned agent only.**
+ Only the assigned agent may execute. Non-assigned agents must not take over. They may speak only to correct a clear factual error or prevent wasted work.
+
+9. **No self-approval.**
+ Do not self-approve your own work. If review is required, it must be done by the user or a different assigned participant.
+
+10. **Claims are not proof.**
+ Do not say work is implemented, fixed, reverted, or verified unless you actually did or checked it. In a shared workspace, claims remain untrusted until independently verified.
+
+11. **Targeted PTY delivery — address who should receive.**
+ Delivery recipients are resolved from the \`to\` param and body @mentions combined. Use \`@all\` to reach every participant. LLM messages with no recipients in either field are persisted in history but not PTY-delivered to any agent terminal. Always address the intended recipient(s) — via @mention in the body or the \`to\` param — so your message actually reaches them.
+`;
+
+/** Get the rules file path for a specific project. */
+function getRulesPath(projectId: string): string {
+ const dir = projectDataDir(projectId, 'chat');
+ return join(dir, RULES_FILENAME);
+}
+
+/** Get the effective rules for the active project (per-project override or default). */
+export function getEffectiveRules(projectId?: string | null): string {
+ const pid = projectId ?? getActiveProject()?.id;
+ if (pid) {
+ const rulesPath = getRulesPath(pid);
+ if (existsSync(rulesPath)) {
+ try {
+ const content = readFileSync(rulesPath, 'utf8').trim();
+ if (content) return content;
+ } catch {
+ // Fall through to default
+ }
+ }
+ }
+ return DEFAULT_RULES;
+}
+
+/** Get the hardcoded default rules (for reference/reset). */
+export function getDefaultRules(): string {
+ return DEFAULT_RULES;
+}
+
+/** Save per-project rules override. */
+export function saveProjectRules(projectId: string, rules: string): void {
+ const dir = projectDataDir(projectId, 'chat');
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, RULES_FILENAME), rules, 'utf8');
+}
+
+/** Delete per-project rules override (reverts to default). */
+export function deleteProjectRules(projectId: string): boolean {
+ const rulesPath = getRulesPath(projectId);
+ if (existsSync(rulesPath)) {
+ unlinkSync(rulesPath);
+ return true;
+ }
+ return false;
+}
+
+/** Check whether a per-project override exists. */
+export function hasProjectRules(projectId?: string | null): boolean {
+ const pid = projectId ?? getActiveProject()?.id;
+ if (!pid) return false;
+ return existsSync(getRulesPath(pid));
+}
diff --git a/src/apps/chat/services/chat-store.test.ts b/src/apps/chat/services/chat-store.test.ts
new file mode 100644
index 0000000..080aeac
--- /dev/null
+++ b/src/apps/chat/services/chat-store.test.ts
@@ -0,0 +1,175 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { existsSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+const TEST_ROOT = join(tmpdir(), 'devglide-chat-store-tests');
+
+vi.mock('../../../packages/paths.js', () => ({
+ projectDataDir: (projectId: string, sub: string) => join(TEST_ROOT, projectId, sub),
+}));
+
+vi.mock('../../../project-context.js', () => ({
+ getActiveProject: () => ({ id: 'chat-store-project', name: 'Chat Store', path: '/tmp/chat-store-project' }),
+}));
+
+const { appendMessage, appendPipeEvent, clearMessages, readMessages, readPipeEvents } = await import('./chat-store.js');
+
+afterEach(() => {
+ rmSync(TEST_ROOT, { recursive: true, force: true });
+});
+
+describe('chat-store', () => {
+ it('persists and reads messages', () => {
+ appendMessage({
+ from: 'user',
+ to: null,
+ body: 'Hello world',
+ type: 'message',
+ });
+
+ const messages = readMessages();
+ expect(messages).toHaveLength(1);
+ expect(messages[0]?.body).toBe('Hello world');
+ });
+
+ it('clears persisted message history', () => {
+ appendMessage({
+ from: 'user',
+ to: null,
+ body: 'test message',
+ type: 'message',
+ });
+
+ clearMessages();
+
+ expect(readMessages()).toEqual([]);
+ });
+});
+
+describe('per-pipe JSONL storage', () => {
+ it('dual-writes pipe messages to both unified and per-pipe files', () => {
+ appendMessage({
+ from: 'system',
+ to: null,
+ body: '#pipe-abc123 Stage handoff',
+ type: 'system',
+ pipe: { pipeId: 'abc123', mode: 'linear', role: 'handoff', stage: 1 } as any,
+ });
+
+ appendMessage({
+ from: 'user',
+ to: null,
+ body: 'Regular chat message',
+ type: 'message',
+ });
+
+ // Unified log has both messages
+ const all = readMessages({ limit: 100 });
+ expect(all).toHaveLength(2);
+
+ // Per-pipe read returns only the pipe message
+ const pipeMessages = readMessages({ limit: 100, pipeId: 'abc123' });
+ expect(pipeMessages).toHaveLength(1);
+ expect(pipeMessages[0]?.body).toBe('#pipe-abc123 Stage handoff');
+ });
+
+ it('reads from per-pipe file without parsing unified log', () => {
+ // Write a pipe message (creates per-pipe file)
+ appendMessage({
+ from: 'claude-1',
+ to: null,
+ body: '#pipe-def456 My output',
+ type: 'message',
+ pipe: { pipeId: 'def456', mode: 'merge-all', role: 'fan-out' } as any,
+ });
+
+ // Per-pipe file should exist
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'def456.jsonl'))).toBe(true);
+
+ // Reading with pipeId should use the per-pipe file
+ const result = readMessages({ limit: 100, pipeId: 'def456' });
+ expect(result).toHaveLength(1);
+ expect(result[0]?.from).toBe('claude-1');
+ });
+
+ it('falls back to unified log for pipes without per-pipe file', () => {
+ // Simulate pre-migration data: write directly to unified log with pipe metadata
+ // by appending a message, then deleting the per-pipe file
+ appendMessage({
+ from: 'system',
+ to: null,
+ body: '#pipe-old123 Legacy handoff',
+ type: 'system',
+ pipe: { pipeId: 'old123', mode: 'linear', role: 'handoff', stage: 1 } as any,
+ });
+
+ // Delete the per-pipe file to simulate pre-migration state
+ const pipePath = join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'old123.jsonl');
+ if (existsSync(pipePath)) {
+ rmSync(pipePath);
+ }
+
+ // Should fall back to unified log and filter by pipeId
+ const result = readMessages({ limit: 100, pipeId: 'old123' });
+ expect(result).toHaveLength(1);
+ expect(result[0]?.body).toBe('#pipe-old123 Legacy handoff');
+ });
+
+ it('clearMessages removes per-pipe files', () => {
+ appendMessage({
+ from: 'system',
+ to: null,
+ body: '#pipe-xyz789 Test',
+ type: 'system',
+ pipe: { pipeId: 'xyz789', mode: 'linear', role: 'handoff', stage: 1 } as any,
+ });
+
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'xyz789.jsonl'))).toBe(true);
+
+ clearMessages();
+
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'xyz789.jsonl'))).toBe(false);
+ expect(readMessages({ limit: 100 })).toEqual([]);
+ });
+
+ it('persists pipe UI events without leaking them into chat history', () => {
+ appendPipeEvent({
+ type: 'stage-output',
+ pipeId: 'evt123',
+ from: 'claude-1',
+ role: 'stage-output',
+ stage: 1,
+ content: '#pipe-evt123 intermediate analysis',
+ });
+
+ expect(readMessages({ limit: 100 })).toEqual([]);
+
+ const allEvents = readPipeEvents({ limit: 100 });
+ expect(allEvents).toHaveLength(1);
+ expect(allEvents[0]?.content).toBe('#pipe-evt123 intermediate analysis');
+
+ const pipeEvents = readPipeEvents({ limit: 100, pipeId: 'evt123' });
+ expect(pipeEvents).toHaveLength(1);
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt123.events.jsonl'))).toBe(true);
+ });
+
+ it('clearMessages removes persisted pipe UI events', () => {
+ appendPipeEvent({
+ type: 'instruction',
+ pipeId: 'evt999',
+ assignee: 'codex-2',
+ actionType: 'handoff',
+ stage: 2,
+ });
+
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipe-events.jsonl'))).toBe(true);
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt999.events.jsonl'))).toBe(true);
+
+ clearMessages();
+
+ expect(readPipeEvents({ limit: 100 })).toEqual([]);
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipe-events.jsonl'))).toBe(false);
+ expect(existsSync(join(TEST_ROOT, 'chat-store-project', 'chat', 'pipes', 'evt999.events.jsonl'))).toBe(false);
+ });
+});
diff --git a/src/apps/chat/services/chat-store.ts b/src/apps/chat/services/chat-store.ts
new file mode 100644
index 0000000..7fd3238
--- /dev/null
+++ b/src/apps/chat/services/chat-store.ts
@@ -0,0 +1,309 @@
+import { mkdirSync, appendFileSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
+import { join } from 'path';
+import { randomUUID } from 'crypto';
+import type { ChatMessage, PipeUiEvent } from '../types.js';
+import { getActiveProject } from '../../../project-context.js';
+import { projectDataDir } from '../../../packages/paths.js';
+
+/**
+ * Resolve the chat data directory for a given project.
+ * An explicit projectId avoids relying on the global active-project singleton,
+ * which can point to a different project when the user switches the dashboard.
+ */
+function getChatDir(projectId?: string | null): string | null {
+ const pid = projectId ?? getActiveProject()?.id;
+ if (!pid) return null;
+ return projectDataDir(pid, 'chat');
+}
+
+function getMessagesPath(projectId?: string | null): string | null {
+ const dir = getChatDir(projectId);
+ if (!dir) return null;
+ mkdirSync(dir, { recursive: true });
+ return join(dir, 'messages.jsonl');
+}
+
+function getPipeMessagesPath(pipeId: string, projectId?: string | null): string | null {
+ const dir = getChatDir(projectId);
+ if (!dir) return null;
+ const pipesDir = join(dir, 'pipes');
+ mkdirSync(pipesDir, { recursive: true });
+ return join(pipesDir, `${pipeId}.jsonl`);
+}
+
+function getPipeEventsLogPath(projectId?: string | null): string | null {
+ const dir = getChatDir(projectId);
+ if (!dir) return null;
+ mkdirSync(dir, { recursive: true });
+ return join(dir, 'pipe-events.jsonl');
+}
+
+function getPipeEventsPath(pipeId: string, projectId?: string | null): string | null {
+ const dir = getChatDir(projectId);
+ if (!dir) return null;
+ const pipesDir = join(dir, 'pipes');
+ mkdirSync(pipesDir, { recursive: true });
+ return join(pipesDir, `${pipeId}.events.jsonl`);
+}
+
+export function appendMessage(msg: Omit, projectId?: string | null): ChatMessage {
+ const full: ChatMessage = {
+ id: randomUUID(),
+ ts: new Date().toISOString(),
+ ...msg,
+ };
+
+ const line = JSON.stringify(full) + '\n';
+ const filePath = getMessagesPath(projectId);
+ if (filePath) {
+ appendFileSync(filePath, line);
+ }
+
+ // Dual-write: pipe messages also go to a per-pipe JSONL file for fast scoped reads
+ if (full.pipe?.pipeId) {
+ const pipePath = getPipeMessagesPath(full.pipe.pipeId, projectId);
+ if (pipePath) {
+ appendFileSync(pipePath, line);
+ }
+ }
+
+ return full;
+}
+
+export function readMessages(opts?: { limit?: number; since?: string; pipeId?: string }, projectId?: string | null): ChatMessage[] {
+ // Fast path: read from per-pipe JSONL file when pipeId is specified.
+ // Falls back to scanning the unified log if the per-pipe file doesn't exist
+ // (backward compatibility with pipe data written before per-pipe storage).
+ let filePath: string | null;
+ let needsPipeFilter = false;
+ if (opts?.pipeId) {
+ const pipePath = getPipeMessagesPath(opts.pipeId, projectId);
+ if (pipePath && existsSync(pipePath)) {
+ filePath = pipePath;
+ } else {
+ filePath = getMessagesPath(projectId);
+ needsPipeFilter = true;
+ }
+ } else {
+ filePath = getMessagesPath(projectId);
+ }
+ if (!filePath || !existsSync(filePath)) return [];
+
+ const raw = readFileSync(filePath, 'utf8').trim();
+ if (!raw) return [];
+
+ let messages: ChatMessage[] = raw
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => {
+ try { return JSON.parse(line) as ChatMessage; }
+ catch { return null; }
+ })
+ .filter((m): m is ChatMessage => m !== null);
+
+ // Fallback: filter by pipeId when reading from the unified log
+ if (needsPipeFilter && opts?.pipeId) {
+ messages = messages.filter((m) => m.pipe?.pipeId === opts.pipeId);
+ }
+
+ if (opts?.since) {
+ const sinceDate = new Date(opts.since).getTime();
+ messages = messages.filter((m) => new Date(m.ts).getTime() > sinceDate);
+ }
+
+ const limit = opts?.limit ?? 50;
+ if (messages.length > limit) {
+ messages = messages.slice(-limit);
+ }
+
+ return messages;
+}
+
+export function appendPipeEvent(event: Omit, projectId?: string | null): PipeUiEvent {
+ const full: PipeUiEvent = {
+ id: randomUUID(),
+ ts: new Date().toISOString(),
+ ...event,
+ };
+
+ const line = JSON.stringify(full) + '\n';
+ const filePath = getPipeEventsLogPath(projectId);
+ if (filePath) {
+ appendFileSync(filePath, line);
+ }
+
+ const perPipePath = getPipeEventsPath(full.pipeId, projectId);
+ if (perPipePath) {
+ appendFileSync(perPipePath, line);
+ }
+
+ return full;
+}
+
+export function readPipeEvents(
+ opts?: { limit?: number; since?: string; pipeId?: string },
+ projectId?: string | null,
+): PipeUiEvent[] {
+ let filePath: string | null;
+ let needsPipeFilter = false;
+ if (opts?.pipeId) {
+ const pipePath = getPipeEventsPath(opts.pipeId, projectId);
+ if (pipePath && existsSync(pipePath)) {
+ filePath = pipePath;
+ } else {
+ filePath = getPipeEventsLogPath(projectId);
+ needsPipeFilter = true;
+ }
+ } else {
+ filePath = getPipeEventsLogPath(projectId);
+ }
+ if (!filePath || !existsSync(filePath)) return [];
+
+ const raw = readFileSync(filePath, 'utf8').trim();
+ if (!raw) return [];
+
+ let events: PipeUiEvent[] = raw
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => {
+ try { return JSON.parse(line) as PipeUiEvent; }
+ catch { return null; }
+ })
+ .filter((event): event is PipeUiEvent => event !== null);
+
+ if (needsPipeFilter && opts?.pipeId) {
+ events = events.filter((event) => event.pipeId === opts.pipeId);
+ }
+
+ if (opts?.since) {
+ const sinceDate = new Date(opts.since).getTime();
+ events = events.filter((event) => new Date(event.ts).getTime() > sinceDate);
+ }
+
+ const limit = opts?.limit ?? 50;
+ if (events.length > limit) {
+ events = events.slice(-limit);
+ }
+
+ return events;
+}
+
+
+// ── Participant persistence ──────────────────────────────────────────────────
+
+export interface PersistedParticipant {
+ name: string;
+ model: string | null;
+ paneId: string | null;
+ projectId: string | null;
+ submitKey: string;
+ joinedAt: string;
+ lastSeen: string;
+ joinedVia?: 'rest' | 'mcp' | null;
+ permissionMode?: 'supervised' | 'auto-accept' | 'unrestricted' | null;
+}
+
+function getParticipantsPath(projectId?: string | null): string | null {
+ const dir = getChatDir(projectId);
+ if (!dir) return null;
+ mkdirSync(dir, { recursive: true });
+ return join(dir, 'participants.json');
+}
+
+export function saveParticipants(participants: PersistedParticipant[], projectId?: string | null): void {
+ const filePath = getParticipantsPath(projectId);
+ if (!filePath) return;
+ writeFileSync(filePath, JSON.stringify(participants, null, 2));
+}
+
+export function loadParticipants(projectId?: string | null): PersistedParticipant[] {
+ const filePath = getParticipantsPath(projectId);
+ if (!filePath || !existsSync(filePath)) return [];
+ try {
+ const raw = readFileSync(filePath, 'utf8');
+ return JSON.parse(raw) as PersistedParticipant[];
+ } catch {
+ return [];
+ }
+}
+
+
+export function clearMessages(projectId?: string | null): void {
+ const filePath = getMessagesPath(projectId);
+ if (filePath && existsSync(filePath)) {
+ writeFileSync(filePath, '');
+ }
+ const pipeEventsPath = getPipeEventsLogPath(projectId);
+ if (pipeEventsPath && existsSync(pipeEventsPath)) {
+ unlinkSync(pipeEventsPath);
+ }
+ // Also clear per-pipe JSONL files
+ const dir = getChatDir(projectId);
+ if (dir) {
+ const pipesDir = join(dir, 'pipes');
+ if (existsSync(pipesDir)) {
+ for (const file of readdirSync(pipesDir)) {
+ if (file.endsWith('.jsonl')) {
+ unlinkSync(join(pipesDir, file));
+ }
+ }
+ }
+ }
+}
+
+
+// ── Pipe message queries ─────────────────────────────────────────────────────
+
+/** Read all messages that carry pipe metadata for a given pipeId.
+ * Uses the per-pipe JSONL file for O(pipe messages) instead of O(all messages). */
+export function readPipeMessages(pipeId: string, projectId?: string | null): ChatMessage[] {
+ return readMessages({ limit: 10000, pipeId }, projectId);
+}
+
+// ── Pipe recovery ───────────────────────────────────────────────────────────
+
+/** Discover all pipe IDs that have per-pipe event files on disk.
+ * Returns pipeIds extracted from filenames matching `{pipeId}.events.jsonl`. */
+export function discoverPersistedPipeIds(projectId?: string | null): string[] {
+ const dir = getChatDir(projectId);
+ if (!dir) return [];
+ const pipesDir = join(dir, 'pipes');
+ if (!existsSync(pipesDir)) return [];
+ const pipeIds: string[] = [];
+ for (const file of readdirSync(pipesDir)) {
+ const match = file.match(/^([a-f0-9]+)\.events\.jsonl$/);
+ if (match) pipeIds.push(match[1]);
+ }
+ return pipeIds;
+}
+
+/** Remove per-pipe JSONL files for the given pipeIds. */
+export function removePipeFiles(pipeIds: string[], projectId?: string | null): void {
+ const dir = getChatDir(projectId);
+ if (!dir) return;
+ const pipesDir = join(dir, 'pipes');
+ for (const pipeId of pipeIds) {
+ for (const suffix of ['.jsonl', '.events.jsonl']) {
+ const filePath = join(pipesDir, `${pipeId}${suffix}`);
+ if (existsSync(filePath)) {
+ try { unlinkSync(filePath); } catch { /* ignore */ }
+ }
+ }
+ }
+}
+
+/** Read all events for a specific pipe from its per-pipe events file. */
+export function readAllPipeEvents(pipeId: string, projectId?: string | null): PipeUiEvent[] {
+ const filePath = getPipeEventsPath(pipeId, projectId);
+ if (!filePath || !existsSync(filePath)) return [];
+ const raw = readFileSync(filePath, 'utf8').trim();
+ if (!raw) return [];
+ return raw
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => {
+ try { return JSON.parse(line) as PipeUiEvent; }
+ catch { return null; }
+ })
+ .filter((e): e is PipeUiEvent => e !== null);
+}
diff --git a/src/apps/chat/services/clock.ts b/src/apps/chat/services/clock.ts
new file mode 100644
index 0000000..977bc14
--- /dev/null
+++ b/src/apps/chat/services/clock.ts
@@ -0,0 +1,30 @@
+/** Injectable clock interface for deterministic testing of time-dependent logic. */
+export interface Clock {
+ now(): number;
+ isoNow(): string;
+}
+
+/** Default clock backed by system time. */
+export const systemClock: Clock = {
+ now: () => Date.now(),
+ isoNow: () => new Date().toISOString(),
+};
+
+/** Controllable clock for deterministic tests. */
+export interface TestClock extends Clock {
+ advance(ms: number): void;
+ set(ms: number): void;
+ currentMs(): number;
+}
+
+/** Create a controllable clock for tests. */
+export function createTestClock(startMs: number = 1767225600000): TestClock {
+ let ms = startMs;
+ return {
+ now: () => ms,
+ isoNow: () => new Date(ms).toISOString(),
+ advance(delta: number) { ms += delta; },
+ set(value: number) { ms = value; },
+ currentMs: () => ms,
+ };
+}
diff --git a/src/apps/chat/services/payload-store.test.ts b/src/apps/chat/services/payload-store.test.ts
new file mode 100644
index 0000000..b6e50a1
--- /dev/null
+++ b/src/apps/chat/services/payload-store.test.ts
@@ -0,0 +1,418 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as payloadStore from './payload-store.js';
+import { createTestClock } from './clock.js';
+
+beforeEach(() => {
+ payloadStore._resetForTest();
+});
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function createTestPayload(overrides?: {
+ pipeId?: string;
+ stageId?: string;
+ content?: string;
+ producedBy?: string;
+ sourceStage?: number;
+}) {
+ return payloadStore.createPayload(
+ overrides?.pipeId ?? 'pipe-1',
+ overrides?.stageId ?? 'linear:1',
+ overrides?.content ?? 'test payload content',
+ 'proj-1',
+ {
+ producedBy: overrides?.producedBy ?? 'alice',
+ sourceStage: overrides?.sourceStage,
+ },
+ );
+}
+
+// ── createPayload ────────────────────────────────────────────────────────────
+
+describe('createPayload', () => {
+ it('creates a payload with correct initial state', () => {
+ const result = createTestPayload();
+ expect(result.ok).toBe(true);
+ expect(result.payload).toBeDefined();
+
+ const p = result.payload!;
+ expect(p.pipeId).toBe('pipe-1');
+ expect(p.stageId).toBe('linear:1');
+ expect(p.content).toBe('test payload content');
+ expect(p.contentVersion).toBe(1);
+ expect(p.status).toBe('active');
+ expect(p.producedBy).toBe('alice');
+ expect(p.archivedAt).toBeNull();
+ expect(p.deletedAt).toBeNull();
+ });
+
+ it('computes SHA-256 content hash', () => {
+ const result = createTestPayload();
+ const p = result.payload!;
+ expect(p.contentHash).toBeTruthy();
+ expect(p.contentHash).toHaveLength(64); // SHA-256 hex = 64 chars
+ });
+
+ it('computes byte length correctly', () => {
+ const result = createTestPayload({ content: 'hello' });
+ expect(result.payload!.sizeBytes).toBe(5);
+ });
+
+ it('handles multi-byte characters in size calculation', () => {
+ const result = createTestPayload({ content: '日本語' }); // 3 chars, 9 bytes in UTF-8
+ expect(result.payload!.sizeBytes).toBe(9);
+ });
+
+ it('rejects payload exceeding size limit', () => {
+ payloadStore.setMaxPayloadBytes(10);
+ const result = createTestPayload({ content: 'this exceeds the limit' });
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('PAYLOAD_TOO_LARGE');
+ });
+
+ it('indexes payload by stage', () => {
+ createTestPayload();
+ const found = payloadStore.getPayloadByStage('pipe-1', 'linear:1', 'proj-1');
+ expect(found).toBeDefined();
+ expect(found!.content).toBe('test payload content');
+ });
+});
+
+// ── getPayload ───────────────────────────────────────────────────────────────
+
+describe('getPayload', () => {
+ it('returns payload by ID', () => {
+ const { payload } = createTestPayload();
+ const found = payloadStore.getPayload(payload!.payloadId, 'proj-1');
+ expect(found).toBeDefined();
+ expect(found!.payloadId).toBe(payload!.payloadId);
+ });
+
+ it('returns undefined for deleted payloads', () => {
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ expect(payloadStore.getPayload(payload!.payloadId, 'proj-1')).toBeUndefined();
+ });
+
+ it('returns undefined for nonexistent IDs', () => {
+ expect(payloadStore.getPayload('nonexistent', 'proj-1')).toBeUndefined();
+ });
+});
+
+// ── getPayloadMeta ───────────────────────────────────────────────────────────
+
+describe('getPayloadMeta', () => {
+ it('returns metadata with content redacted', () => {
+ const { payload } = createTestPayload();
+ const meta = payloadStore.getPayloadMeta(payload!.payloadId, 'proj-1');
+ expect(meta).toBeDefined();
+ expect(meta!.content).toBe('[redacted]');
+ expect(meta!.pipeId).toBe('pipe-1');
+ });
+
+ it('returns metadata even for deleted payloads', () => {
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ const meta = payloadStore.getPayloadMeta(payload!.payloadId, 'proj-1');
+ expect(meta).toBeDefined();
+ expect(meta!.status).toBe('deleted');
+ });
+});
+
+// ── fetchPayloadContent ──────────────────────────────────────────────────────
+
+describe('fetchPayloadContent', () => {
+ it('returns content with integrity verification', () => {
+ const { payload } = createTestPayload();
+ const result = payloadStore.fetchPayloadContent(payload!.payloadId, 'proj-1');
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.content).toBe('test payload content');
+ expect(result.contentHash).toBe(payload!.contentHash);
+ expect(result.contentVersion).toBe(1);
+ });
+
+ it('rejects fetch for deleted payloads', () => {
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ const result = payloadStore.fetchPayloadContent(payload!.payloadId, 'proj-1');
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe('PAYLOAD_DELETED');
+ });
+
+ it('rejects fetch for nonexistent payloads', () => {
+ const result = payloadStore.fetchPayloadContent('nonexistent', 'proj-1');
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe('PAYLOAD_NOT_FOUND');
+ });
+});
+
+// ── updatePayloadContent ─────────────────────────────────────────────────────
+
+describe('updatePayloadContent', () => {
+ it('updates content and increments version', () => {
+ const { payload } = createTestPayload();
+ const result = payloadStore.updatePayloadContent(
+ payload!.payloadId, 'updated content', 'proj-1',
+ );
+ expect(result.ok).toBe(true);
+ expect(result.payload!.content).toBe('updated content');
+ expect(result.payload!.contentVersion).toBe(2);
+ expect(result.payload!.contentHash).not.toBe(payload!.contentHash);
+ });
+
+ it('rejects update on deleted payload', () => {
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ const result = payloadStore.updatePayloadContent(
+ payload!.payloadId, 'new', 'proj-1',
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('PAYLOAD_DELETED');
+ });
+
+ it('rejects update exceeding size limit', () => {
+ const { payload } = createTestPayload();
+ payloadStore.setMaxPayloadBytes(10);
+ const result = payloadStore.updatePayloadContent(
+ payload!.payloadId, 'this is way too long', 'proj-1',
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('PAYLOAD_TOO_LARGE');
+ });
+});
+
+// ── archivePayload ───────────────────────────────────────────────────────────
+
+describe('archivePayload', () => {
+ it('marks payload as archived', () => {
+ const { payload } = createTestPayload();
+ const result = payloadStore.archivePayload(payload!.payloadId, 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.payload!.status).toBe('archived');
+ expect(result.payload!.archivedAt).toBeTruthy();
+ });
+
+ it('archived payloads are still readable', () => {
+ const { payload } = createTestPayload();
+ payloadStore.archivePayload(payload!.payloadId, 'proj-1');
+ const found = payloadStore.getPayload(payload!.payloadId, 'proj-1');
+ expect(found).toBeDefined();
+ expect(found!.content).toBe('test payload content');
+ });
+
+ it('rejects archive on deleted payload', () => {
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ const result = payloadStore.archivePayload(payload!.payloadId, 'proj-1');
+ expect(result.ok).toBe(false);
+ });
+});
+
+// ── deletePayload ────────────────────────────────────────────────────────────
+
+describe('deletePayload', () => {
+ it('soft-deletes payload (removes content, preserves metadata)', () => {
+ const { payload } = createTestPayload();
+ const result = payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.payload!.status).toBe('deleted');
+ expect(result.payload!.content).toBe('');
+ expect(result.payload!.sizeBytes).toBe(0);
+ expect(result.payload!.deletedAt).toBeTruthy();
+ });
+});
+
+// ── archivePipePayloads ──────────────────────────────────────────────────────
+
+describe('archivePipePayloads', () => {
+ it('archives all active payloads for a pipe', () => {
+ createTestPayload({ stageId: 'linear:1' });
+ createTestPayload({ stageId: 'linear:2' });
+ createTestPayload({ pipeId: 'pipe-2', stageId: 'linear:1' }); // different pipe
+
+ const count = payloadStore.archivePipePayloads('pipe-1', 'proj-1');
+ expect(count).toBe(2);
+
+ // Different pipe should be unaffected
+ const other = payloadStore.getPayloadByStage('pipe-2', 'linear:1', 'proj-1');
+ expect(other!.status).toBe('active');
+ });
+});
+
+// ── cleanupExpiredPayloads ───────────────────────────────────────────────────
+
+describe('cleanupExpiredPayloads', () => {
+ it('removes archived payloads older than TTL', () => {
+ const clock = createTestClock();
+ payloadStore.setClock(clock);
+
+ const { payload } = createTestPayload();
+ payloadStore.archivePayload(payload!.payloadId, 'proj-1');
+
+ // Not enough time
+ clock.advance(1000);
+ expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(0);
+
+ // Enough time
+ clock.advance(5000);
+ expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(1);
+ });
+
+ it('does not remove active payloads', () => {
+ const clock = createTestClock();
+ payloadStore.setClock(clock);
+
+ createTestPayload();
+ clock.advance(100_000);
+ expect(payloadStore.cleanupExpiredPayloads('proj-1', 1000)).toBe(0);
+ });
+
+ it('removes deleted payloads after TTL', () => {
+ const clock = createTestClock();
+ payloadStore.setClock(clock);
+
+ const { payload } = createTestPayload();
+ payloadStore.deletePayload(payload!.payloadId, 'proj-1');
+
+ clock.advance(10_000);
+ expect(payloadStore.cleanupExpiredPayloads('proj-1', 5000)).toBe(1);
+ });
+});
+
+// ── getStorageStats ──────────────────────────────────────────────────────────
+
+describe('getStorageStats', () => {
+ it('computes correct stats', () => {
+ createTestPayload({ stageId: 'linear:1', content: 'hello' }); // 5 bytes
+ createTestPayload({ stageId: 'linear:2', content: 'world!' }); // 6 bytes
+ const { payload: p3 } = createTestPayload({ stageId: 'linear:3', content: 'test' }); // 4 bytes
+ payloadStore.archivePayload(p3!.payloadId, 'proj-1');
+
+ const stats = payloadStore.getStorageStats('proj-1');
+ expect(stats.totalPayloads).toBe(3);
+ expect(stats.activePayloads).toBe(2);
+ expect(stats.archivedPayloads).toBe(1);
+ expect(stats.deletedPayloads).toBe(0);
+ expect(stats.activeBytes).toBe(11);
+ expect(stats.totalBytes).toBe(15);
+ });
+});
+
+// ── getPayloadsByPipe ────────────────────────────────────────────────────────
+
+describe('getPayloadsByPipe', () => {
+ it('lists active and archived payloads, excludes deleted', () => {
+ createTestPayload({ stageId: 'linear:1' });
+ const { payload: p2 } = createTestPayload({ stageId: 'linear:2' });
+ payloadStore.archivePayload(p2!.payloadId, 'proj-1');
+ const { payload: p3 } = createTestPayload({ stageId: 'linear:3' });
+ payloadStore.deletePayload(p3!.payloadId, 'proj-1');
+
+ const payloads = payloadStore.getPayloadsByPipe('pipe-1', 'proj-1');
+ expect(payloads).toHaveLength(2); // active + archived, not deleted
+ });
+});
+
+// ── Recovery ─────────────────────────────────────────────────────────────────
+
+describe('rehydrateFromEvents', () => {
+ it('recreates payload from creation event', () => {
+ const events: payloadStore.PayloadRecoveryEvent[] = [
+ {
+ type: 'payload-created',
+ payloadId: 'p-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ content: 'recovered content',
+ producedBy: 'alice',
+ sourceStage: 0,
+ },
+ ];
+
+ const active = payloadStore.rehydrateFromEvents(events, 'proj-1');
+ expect(active).toContain('p-001');
+
+ const payload = payloadStore.getPayload('p-001', 'proj-1');
+ expect(payload).toBeDefined();
+ expect(payload!.content).toBe('recovered content');
+ expect(payload!.producedBy).toBe('alice');
+ });
+
+ it('replays archive events', () => {
+ const events: payloadStore.PayloadRecoveryEvent[] = [
+ {
+ type: 'payload-created',
+ payloadId: 'p-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ content: 'test',
+ },
+ {
+ type: 'payload-archived',
+ payloadId: 'p-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ },
+ ];
+
+ const active = payloadStore.rehydrateFromEvents(events, 'proj-1');
+ expect(active).not.toContain('p-001');
+
+ const payload = payloadStore.getPayload('p-001', 'proj-1');
+ expect(payload!.status).toBe('archived');
+ });
+});
+
+// ── Recovery timestamp fidelity ──────────────────────────────────────────────
+
+describe('recovery timestamp fidelity', () => {
+ it('preserves original event timestamps during rehydration', () => {
+ const events: payloadStore.PayloadRecoveryEvent[] = [
+ {
+ type: 'payload-created',
+ payloadId: 'p-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ content: 'test content',
+ producedBy: 'alice',
+ ts: '2026-03-15T10:00:00.000Z',
+ },
+ {
+ type: 'payload-archived',
+ payloadId: 'p-001',
+ pipeId: 'pipe-1',
+ stageId: 'linear:1',
+ ts: '2026-03-15T12:00:00.000Z',
+ },
+ ];
+
+ payloadStore.rehydrateFromEvents(events, 'proj-1');
+ const payload = payloadStore.getPayload('p-001', 'proj-1');
+
+ // Timestamps should match the persisted events, not the current clock
+ expect(payload!.createdAt).toBe('2026-03-15T10:00:00.000Z');
+ expect(payload!.archivedAt).toBe('2026-03-15T12:00:00.000Z');
+ expect(payload!.updatedAt).toBe('2026-03-15T12:00:00.000Z');
+ });
+});
+
+// ── Clock injection ──────────────────────────────────────────────────────────
+
+describe('clock injection', () => {
+ it('uses injected clock for timestamps', () => {
+ const clock = createTestClock(1700000000000);
+ payloadStore.setClock(clock);
+
+ const { payload } = createTestPayload();
+ expect(payload!.createdAt).toBe('2023-11-14T22:13:20.000Z');
+
+ clock.advance(3000);
+ payloadStore.updatePayloadContent(payload!.payloadId, 'updated', 'proj-1');
+
+ const updated = payloadStore.getPayload(payload!.payloadId, 'proj-1');
+ expect(updated!.updatedAt).toBe('2023-11-14T22:13:23.000Z');
+ });
+});
diff --git a/src/apps/chat/services/payload-store.ts b/src/apps/chat/services/payload-store.ts
new file mode 100644
index 0000000..4278bc0
--- /dev/null
+++ b/src/apps/chat/services/payload-store.ts
@@ -0,0 +1,511 @@
+import { randomUUID, createHash } from 'crypto';
+import type { PayloadStatus } from '../types.js';
+import type { Clock } from './clock.js';
+import { systemClock } from './clock.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+/** An authoritative payload that holds stage input or output content.
+ * Payloads are stored separately from assignments so they can be fetched
+ * on demand rather than pushed in full via PTY. */
+export interface Payload {
+ payloadId: string; // stable UUID — immutable once created
+ pipeId: string;
+ stageId: string; // matches the assignment's stageId
+ content: string; // the actual payload content (markdown/text)
+ contentHash: string; // SHA-256 hex digest for integrity verification
+ contentVersion: number; // increments if content is updated (rare — mostly immutable)
+ sizeBytes: number; // byte length of content (UTF-8)
+ status: PayloadStatus;
+
+ // Timestamps (ISO 8601)
+ createdAt: string;
+ updatedAt: string; // last mutation time (content update or status change)
+ archivedAt: string | null;
+ deletedAt: string | null;
+
+ // Provenance
+ producedBy: string | null; // participant who produced this content
+ sourceStage: number | null; // the stage number that produced this output (for linear input payloads)
+}
+
+/** Error codes for payload operations. */
+export type PayloadErrorCode =
+ | 'PAYLOAD_NOT_FOUND'
+ | 'PAYLOAD_DELETED'
+ | 'PAYLOAD_TOO_LARGE'
+ | 'HASH_MISMATCH';
+
+/** Result of a payload operation. */
+export interface PayloadResult {
+ ok: boolean;
+ error?: string;
+ code?: PayloadErrorCode;
+ payload?: Payload;
+}
+
+// ── Configuration ─────────────────────────────────────────────────────────────
+
+/** Maximum payload size in bytes (default 2 MB). Prevents runaway content from exhausting memory. */
+export const DEFAULT_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
+
+/** Default retention for archived payloads: 24 hours.
+ * Active payloads are never cleaned up — only archived/deleted ones are eligible. */
+export const DEFAULT_PAYLOAD_TTL_MS = 24 * 60 * 60 * 1000;
+
+// ── Storage ───────────────────────────────────────────────────────────────────
+
+// projectId -> (payloadId -> Payload)
+const stores = new Map>();
+
+// projectId -> (pipeId:stageId -> payloadId) — latest payload per stage
+const stageIndex = new Map>();
+
+let clock: Clock = systemClock;
+let maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES;
+
+/** Override the clock used for timestamps (for testing). */
+export function setClock(c: Clock): void {
+ clock = c;
+}
+
+/** Override the maximum payload size (for testing). */
+export function setMaxPayloadBytes(max: number): void {
+ maxPayloadBytes = max;
+}
+
+function getProjectStore(projectId: string | null): Map {
+ let store = stores.get(projectId);
+ if (!store) { store = new Map(); stores.set(projectId, store); }
+ return store;
+}
+
+function getStageIndex(projectId: string | null): Map {
+ let index = stageIndex.get(projectId);
+ if (!index) { index = new Map(); stageIndex.set(projectId, index); }
+ return index;
+}
+
+function stageKey(pipeId: string, stageId: string): string {
+ return `${pipeId}:${stageId}`;
+}
+
+function computeHash(content: string): string {
+ return createHash('sha256').update(content, 'utf8').digest('hex');
+}
+
+function byteLength(content: string): number {
+ return Buffer.byteLength(content, 'utf8');
+}
+
+// ── Payload lifecycle ─────────────────────────────────────────────────────────
+
+/** Create a new payload for a pipe stage.
+ * Content is stored in-memory with a SHA-256 integrity hash.
+ * Returns an error if content exceeds the size limit. */
+export function createPayload(
+ pipeId: string,
+ stageId: string,
+ content: string,
+ projectId: string | null,
+ opts?: { producedBy?: string; sourceStage?: number },
+): PayloadResult {
+ const size = byteLength(content);
+ if (size > maxPayloadBytes) {
+ return {
+ ok: false,
+ code: 'PAYLOAD_TOO_LARGE',
+ error: `Payload size ${size} bytes exceeds limit of ${maxPayloadBytes} bytes`,
+ };
+ }
+
+ const now = clock.isoNow();
+ const payload: Payload = {
+ payloadId: randomUUID(),
+ pipeId,
+ stageId,
+ content,
+ contentHash: computeHash(content),
+ contentVersion: 1,
+ sizeBytes: size,
+ status: 'active',
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ deletedAt: null,
+ producedBy: opts?.producedBy ?? null,
+ sourceStage: opts?.sourceStage ?? null,
+ };
+
+ const store = getProjectStore(projectId);
+ store.set(payload.payloadId, payload);
+
+ // Update stage index
+ const sIndex = getStageIndex(projectId);
+ sIndex.set(stageKey(pipeId, stageId), payload.payloadId);
+
+ return { ok: true, payload: { ...payload } };
+}
+
+/** Get a payload by ID. Returns undefined if not found.
+ * Deleted payloads return undefined — use getPayloadMeta for audit queries. */
+export function getPayload(payloadId: string, projectId: string | null): Payload | undefined {
+ const payload = getProjectStore(projectId).get(payloadId);
+ if (!payload || payload.status === 'deleted') return undefined;
+ return payload;
+}
+
+/** Get payload metadata without content (for status checks and audit).
+ * Returns the payload even if deleted, but with content redacted. */
+export function getPayloadMeta(
+ payloadId: string,
+ projectId: string | null,
+): Omit & { content: '[redacted]' } | undefined {
+ const payload = getProjectStore(projectId).get(payloadId);
+ if (!payload) return undefined;
+ return { ...payload, content: '[redacted]' };
+}
+
+/** Get the latest payload for a specific pipe stage. */
+export function getPayloadByStage(
+ pipeId: string,
+ stageId: string,
+ projectId: string | null,
+): Payload | undefined {
+ const sIndex = getStageIndex(projectId);
+ const id = sIndex.get(stageKey(pipeId, stageId));
+ if (!id) return undefined;
+ return getPayload(id, projectId);
+}
+
+/** Fetch payload content with integrity verification.
+ * Returns the content only if the hash matches.
+ * This is the authoritative fetch path — clients call this to get the real payload. */
+export function fetchPayloadContent(
+ payloadId: string,
+ projectId: string | null,
+): { ok: true; content: string; contentHash: string; contentVersion: number } | { ok: false; error: string; code: PayloadErrorCode } {
+ const payload = getProjectStore(projectId).get(payloadId);
+ if (!payload) {
+ return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` };
+ }
+ if (payload.status === 'deleted') {
+ return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} has been deleted` };
+ }
+
+ // Integrity check — verify hash still matches content
+ const currentHash = computeHash(payload.content);
+ if (currentHash !== payload.contentHash) {
+ return { ok: false, code: 'HASH_MISMATCH', error: `Payload ${payloadId} integrity check failed` };
+ }
+
+ return {
+ ok: true,
+ content: payload.content,
+ contentHash: payload.contentHash,
+ contentVersion: payload.contentVersion,
+ };
+}
+
+/** Update the content of a payload (rare — mainly for error correction).
+ * Increments contentVersion and recomputes the hash. */
+export function updatePayloadContent(
+ payloadId: string,
+ newContent: string,
+ projectId: string | null,
+): PayloadResult {
+ const store = getProjectStore(projectId);
+ const payload = store.get(payloadId);
+ if (!payload) {
+ return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` };
+ }
+ if (payload.status === 'deleted') {
+ return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} has been deleted` };
+ }
+
+ const size = byteLength(newContent);
+ if (size > maxPayloadBytes) {
+ return {
+ ok: false,
+ code: 'PAYLOAD_TOO_LARGE',
+ error: `Payload size ${size} bytes exceeds limit of ${maxPayloadBytes} bytes`,
+ };
+ }
+
+ payload.content = newContent;
+ payload.contentHash = computeHash(newContent);
+ payload.contentVersion++;
+ payload.sizeBytes = size;
+ payload.updatedAt = clock.isoNow();
+
+ return { ok: true, payload: { ...payload } };
+}
+
+// ── Status transitions ────────────────────────────────────────────────────────
+
+/** Archive a payload — marks it as no longer needed but retains content for TTL period.
+ * Typically called when the assignment using this payload reaches a terminal state. */
+export function archivePayload(payloadId: string, projectId: string | null): PayloadResult {
+ const store = getProjectStore(projectId);
+ const payload = store.get(payloadId);
+ if (!payload) {
+ return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` };
+ }
+ if (payload.status === 'deleted') {
+ return { ok: false, code: 'PAYLOAD_DELETED', error: `Payload ${payloadId} already deleted` };
+ }
+
+ payload.status = 'archived';
+ payload.archivedAt = clock.isoNow();
+ payload.updatedAt = payload.archivedAt;
+
+ return { ok: true, payload: { ...payload } };
+}
+
+/** Soft-delete a payload — removes content but preserves metadata for audit.
+ * Content is replaced with an empty string and hash is zeroed. */
+export function deletePayload(payloadId: string, projectId: string | null): PayloadResult {
+ const store = getProjectStore(projectId);
+ const payload = store.get(payloadId);
+ if (!payload) {
+ return { ok: false, code: 'PAYLOAD_NOT_FOUND', error: `Payload ${payloadId} not found` };
+ }
+
+ payload.content = '';
+ payload.contentHash = computeHash('');
+ payload.sizeBytes = 0;
+ payload.status = 'deleted';
+ payload.deletedAt = clock.isoNow();
+ payload.updatedAt = payload.deletedAt;
+
+ return { ok: true, payload: { ...payload } };
+}
+
+/** Archive all active payloads for a pipe.
+ * Called when a pipe reaches a terminal state. Returns the count of archived payloads. */
+export function archivePipePayloads(pipeId: string, projectId: string | null): number {
+ const store = getProjectStore(projectId);
+ let count = 0;
+ for (const payload of store.values()) {
+ if (payload.pipeId === pipeId && payload.status === 'active') {
+ payload.status = 'archived';
+ payload.archivedAt = clock.isoNow();
+ payload.updatedAt = payload.archivedAt;
+ count++;
+ }
+ }
+ return count;
+}
+
+// ── Cleanup ───────────────────────────────────────────────────────────────────
+
+/** Remove archived/deleted payloads older than the given TTL.
+ * Active payloads are never removed — archive them first.
+ * Returns the number of payloads removed from memory. */
+export function cleanupExpiredPayloads(
+ projectId: string | null,
+ ttlMs: number = DEFAULT_PAYLOAD_TTL_MS,
+): number {
+ const store = getProjectStore(projectId);
+ const now = clock.now();
+ let removed = 0;
+
+ for (const [id, payload] of store) {
+ if (payload.status === 'active') continue;
+
+ const refTs = payload.deletedAt ?? payload.archivedAt ?? payload.updatedAt;
+ if (now - new Date(refTs).getTime() >= ttlMs) {
+ store.delete(id);
+
+ // Clean up stage index
+ const sIndex = getStageIndex(projectId);
+ const key = stageKey(payload.pipeId, payload.stageId);
+ if (sIndex.get(key) === id) {
+ sIndex.delete(key);
+ }
+
+ removed++;
+ }
+ }
+
+ return removed;
+}
+
+/** List all payloads for a pipe (active and archived, excluding deleted). */
+export function getPayloadsByPipe(pipeId: string, projectId: string | null): Payload[] {
+ const store = getProjectStore(projectId);
+ const result: Payload[] = [];
+ for (const payload of store.values()) {
+ if (payload.pipeId === pipeId && payload.status !== 'deleted') {
+ result.push(payload);
+ }
+ }
+ return result;
+}
+
+/** Get aggregate storage stats for a project. */
+export function getStorageStats(projectId: string | null): {
+ totalPayloads: number;
+ activePayloads: number;
+ archivedPayloads: number;
+ deletedPayloads: number;
+ totalBytes: number;
+ activeBytes: number;
+} {
+ const store = getProjectStore(projectId);
+ let total = 0, active = 0, archived = 0, deleted = 0;
+ let totalBytes = 0, activeBytes = 0;
+
+ for (const payload of store.values()) {
+ total++;
+ totalBytes += payload.sizeBytes;
+ switch (payload.status) {
+ case 'active': active++; activeBytes += payload.sizeBytes; break;
+ case 'archived': archived++; break;
+ case 'deleted': deleted++; break;
+ }
+ }
+
+ return { totalPayloads: total, activePayloads: active, archivedPayloads: archived, deletedPayloads: deleted, totalBytes, activeBytes };
+}
+
+/** Get all projectIds that have payload data in the store. */
+export function getTrackedProjectIds(): Array {
+ return [...stores.keys()];
+}
+
+// ── Recovery ──────────────────────────────────────────────────────────────────
+
+/** Payload recovery event — persisted alongside assignment events. */
+export interface PayloadRecoveryEvent {
+ type: 'payload-created' | 'payload-updated' | 'payload-archived' | 'payload-deleted';
+ payloadId: string;
+ pipeId: string;
+ stageId: string;
+ content?: string; // only on 'created' and 'updated'
+ producedBy?: string;
+ sourceStage?: number;
+ ts?: string;
+}
+
+/** Restore persisted timestamp on a recovered payload.
+ * Mutators stamp fresh timestamps during replay — this overwrites them
+ * with the original event timestamps so TTL and audit remain faithful. */
+function restorePayloadTimestamp(
+ payloadId: string,
+ projectId: string | null,
+ eventType: PayloadRecoveryEvent['type'],
+ ts: string,
+): void {
+ const payload = getProjectStore(projectId).get(payloadId);
+ if (!payload) return;
+
+ switch (eventType) {
+ case 'payload-created':
+ payload.createdAt = ts;
+ payload.updatedAt = ts;
+ break;
+ case 'payload-updated':
+ payload.updatedAt = ts;
+ break;
+ case 'payload-archived':
+ payload.archivedAt = ts;
+ payload.updatedAt = ts;
+ break;
+ case 'payload-deleted':
+ payload.deletedAt = ts;
+ payload.updatedAt = ts;
+ break;
+ }
+}
+
+/** Rehydrate payload state from persisted events.
+ * Called on server restart. Preserves original event timestamps for TTL
+ * and audit fidelity. Returns payloadIds that are still active. */
+export function rehydrateFromEvents(
+ events: PayloadRecoveryEvent[],
+ projectId: string | null,
+): string[] {
+ const active: string[] = [];
+
+ for (const event of events) {
+ switch (event.type) {
+ case 'payload-created': {
+ if (event.content === undefined) break;
+ const result = createPayload(
+ event.pipeId,
+ event.stageId,
+ event.content,
+ projectId,
+ { producedBy: event.producedBy, sourceStage: event.sourceStage },
+ );
+ if (result.ok && result.payload) {
+ // Fix the payloadId to match persisted one
+ const store = getProjectStore(projectId);
+ const generated = result.payload.payloadId;
+ if (generated !== event.payloadId) {
+ const payload = store.get(generated);
+ if (payload) {
+ store.delete(generated);
+ payload.payloadId = event.payloadId;
+ store.set(event.payloadId, payload);
+ // Fix stage index
+ const sIndex = getStageIndex(projectId);
+ const key = stageKey(event.pipeId, event.stageId);
+ if (sIndex.get(key) === generated) {
+ sIndex.set(key, event.payloadId);
+ }
+ }
+ }
+ // Restore original creation timestamp
+ if (event.ts) {
+ restorePayloadTimestamp(event.payloadId, projectId, 'payload-created', event.ts);
+ }
+ }
+ break;
+ }
+ case 'payload-updated': {
+ if (event.content === undefined) break;
+ updatePayloadContent(event.payloadId, event.content, projectId);
+ if (event.ts) {
+ restorePayloadTimestamp(event.payloadId, projectId, 'payload-updated', event.ts);
+ }
+ break;
+ }
+ case 'payload-archived': {
+ archivePayload(event.payloadId, projectId);
+ if (event.ts) {
+ restorePayloadTimestamp(event.payloadId, projectId, 'payload-archived', event.ts);
+ }
+ break;
+ }
+ case 'payload-deleted': {
+ deletePayload(event.payloadId, projectId);
+ if (event.ts) {
+ restorePayloadTimestamp(event.payloadId, projectId, 'payload-deleted', event.ts);
+ }
+ break;
+ }
+ }
+ }
+
+ // Collect active payloads
+ const store = getProjectStore(projectId);
+ for (const payload of store.values()) {
+ if (payload.status === 'active') {
+ active.push(payload.payloadId);
+ }
+ }
+
+ return active;
+}
+
+// ── Test helper ───────────────────────────────────────────────────────────────
+
+/** Reset all in-memory state. For testing only. */
+export function _resetForTest(): void {
+ stores.clear();
+ stageIndex.clear();
+ clock = systemClock;
+ maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES;
+}
diff --git a/src/apps/chat/services/pipe-assignment-materializer.test.ts b/src/apps/chat/services/pipe-assignment-materializer.test.ts
new file mode 100644
index 0000000..e2a3ff6
--- /dev/null
+++ b/src/apps/chat/services/pipe-assignment-materializer.test.ts
@@ -0,0 +1,532 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as materializer from './pipe-assignment-materializer.js';
+import * as assignmentStore from './assignment-store.js';
+import * as payloadStore from './payload-store.js';
+
+const PROJECT = 'test-project';
+
+beforeEach(() => {
+ assignmentStore._resetForTest();
+ payloadStore._resetForTest();
+});
+
+// ── materializeAssignment ───────────────────────────────────────────────────
+
+describe('materializeAssignment', () => {
+ it('creates assignment + payload for a handoff action', () => {
+ const result = materializer.materializeAssignment('pipe-1', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Analyze this code',
+ }, PROJECT);
+
+ expect(result).not.toBeNull();
+ expect(result!.assignee).toBe('alice');
+ expect(result!.role).toBe('stage-output');
+ expect(result!.stage).toBe(1);
+ expect(result!.stageId).toBe('linear:1');
+ expect(result!.assignmentId).toBeTruthy();
+ expect(result!.payloadId).toBeTruthy();
+ });
+
+ it('creates assignment + payload for a fan-out-request action', () => {
+ const result = materializer.materializeAssignment('pipe-2', 'merge', {
+ type: 'fan-out-request',
+ targetAssignee: 'bob',
+ body: 'Give your opinion',
+ }, PROJECT);
+
+ expect(result).not.toBeNull();
+ expect(result!.assignee).toBe('bob');
+ expect(result!.role).toBe('fan-out');
+ expect(result!.stageId).toBe('fan-out:bob');
+ expect(result!.stage).toBeUndefined();
+ });
+
+ it('creates assignment + payload for a synth-request action', () => {
+ const result = materializer.materializeAssignment('pipe-3', 'merge', {
+ type: 'synth-request',
+ targetAssignee: 'charlie',
+ body: 'Synthesize all outputs',
+ }, PROJECT);
+
+ expect(result).not.toBeNull();
+ expect(result!.assignee).toBe('charlie');
+ expect(result!.role).toBe('final');
+ expect(result!.stageId).toBe('synth');
+ });
+
+ it('returns null when payload creation fails (e.g. too large)', () => {
+ payloadStore.setMaxPayloadBytes(10); // very small limit
+ const result = materializer.materializeAssignment('pipe-4', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'This body exceeds the tiny payload limit',
+ }, PROJECT);
+
+ expect(result).toBeNull();
+ });
+});
+
+// ── Notification envelope ───────────────────────────────────────────────────
+
+describe('notification envelope', () => {
+ it('contains correct fields in materialized result', () => {
+ const result = materializer.materializeAssignment('pipe-n', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 2,
+ body: 'Stage 2 prompt',
+ }, PROJECT);
+
+ expect(result).not.toBeNull();
+ const n = result!.notification;
+ expect(n.assignmentId).toBe(result!.assignmentId);
+ expect(n.pipeId).toBe('pipe-n');
+ expect(n.stageId).toBe('linear:2');
+ expect(n.role).toBe('stage-output');
+ expect(n.stage).toBe(2);
+ expect(n.attempt).toBe(1);
+ expect(n.payloadId).toBe(result!.payloadId);
+ });
+
+ it('can retrieve notification for an existing assignment', () => {
+ const result = materializer.materializeAssignment('pipe-n2', 'merge', {
+ type: 'fan-out-request',
+ targetAssignee: 'bob',
+ body: 'Fan out prompt',
+ }, PROJECT);
+
+ const notification = materializer.getAssignmentNotification(result!.assignmentId, PROJECT);
+ expect(notification).not.toBeNull();
+ expect(notification!.assignmentId).toBe(result!.assignmentId);
+ expect(notification!.pipeId).toBe('pipe-n2');
+ expect(notification!.role).toBe('fan-out');
+ expect(notification!.payloadId).toBe(result!.payloadId);
+ });
+
+ it('returns null for non-existent assignment', () => {
+ const notification = materializer.getAssignmentNotification('nonexistent', PROJECT);
+ expect(notification).toBeNull();
+ });
+});
+
+// ── Payload integrity ───────────────────────────────────────────────────────
+
+describe('payload integrity', () => {
+ it('stores content with SHA-256 hash', () => {
+ const body = 'Important analysis content';
+ const result = materializer.materializeAssignment('pipe-hash', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body,
+ }, PROJECT);
+
+ expect(result).not.toBeNull();
+
+ // Verify payload content and hash via payload store
+ const fetchResult = payloadStore.fetchPayloadContent(result!.payloadId, PROJECT);
+ expect(fetchResult.ok).toBe(true);
+ if (fetchResult.ok) {
+ expect(fetchResult.content).toBe(body);
+ expect(fetchResult.contentHash).toBeTruthy();
+ expect(fetchResult.contentHash.length).toBe(64); // SHA-256 hex is 64 chars
+ }
+ });
+
+ it('stores content that matches the action body', () => {
+ const body = 'Exact content to verify';
+ const result = materializer.materializeAssignment('pipe-content', 'merge', {
+ type: 'fan-out-request',
+ targetAssignee: 'bob',
+ body,
+ }, PROJECT);
+
+ const payload = payloadStore.getPayload(result!.payloadId, PROJECT);
+ expect(payload).toBeDefined();
+ expect(payload!.content).toBe(body);
+ expect(payload!.pipeId).toBe('pipe-content');
+ expect(payload!.stageId).toBe('fan-out:bob');
+ });
+});
+
+// ── materializePipeAssignments (linear) ─────────────────────────────────────
+
+describe('materializePipeAssignments — linear', () => {
+ it('creates only stage 1 assignment for a linear pipe', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-lin', 'linear', ['alice', 'bob', 'charlie'], 'Analyze step by step', PROJECT,
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0].assignee).toBe('alice');
+ expect(results[0].stage).toBe(1);
+ expect(results[0].role).toBe('stage-output');
+ expect(results[0].stageId).toBe('linear:1');
+ });
+
+ it('creates payload with the prompt as content', () => {
+ const prompt = 'Linear pipe prompt';
+ const results = materializer.materializePipeAssignments(
+ 'pipe-lin2', 'linear', ['alice', 'bob'], prompt, PROJECT,
+ );
+
+ const payload = payloadStore.getPayload(results[0].payloadId, PROJECT);
+ expect(payload!.content).toBe(prompt);
+ });
+});
+
+// ── materializeNextLinearAssignment ─────────────────────────────────────────
+
+describe('materializeNextLinearAssignment', () => {
+ it('creates stage 2 assignment after stage 1 completes', () => {
+ // Materialize stage 1
+ const stage1 = materializer.materializePipeAssignments(
+ 'pipe-next', 'linear', ['alice', 'bob', 'charlie'], 'Initial prompt', PROJECT,
+ );
+ expect(stage1).toHaveLength(1);
+
+ // Complete stage 1 (transition through lifecycle)
+ materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(stage1[0].assignmentId, 'payload_fetched', PROJECT);
+ materializer.completeAssignment(stage1[0].assignmentId, PROJECT);
+
+ // Materialize stage 2
+ const stage2 = materializer.materializeNextLinearAssignment(
+ 'pipe-next', 1, ['alice', 'bob', 'charlie'], 'Stage 1 output', PROJECT,
+ );
+
+ expect(stage2).not.toBeNull();
+ expect(stage2!.assignee).toBe('bob');
+ expect(stage2!.stage).toBe(2);
+ expect(stage2!.stageId).toBe('linear:2');
+ expect(stage2!.role).toBe('stage-output');
+ });
+
+ it('creates stage 3 assignment after stage 2 completes', () => {
+ // Set up stage 1 and complete it
+ materializer.materializePipeAssignments(
+ 'pipe-s3', 'linear', ['a', 'b', 'c'], 'prompt', PROJECT,
+ );
+ const assignments = assignmentStore.getAssignmentsByPipe('pipe-s3', PROJECT);
+ const s1 = assignments[0];
+ assignmentStore.transitionAssignment(s1.assignmentId, 'notified', PROJECT);
+ assignmentStore.transitionAssignment(s1.assignmentId, 'acknowledged', PROJECT);
+ assignmentStore.transitionAssignment(s1.assignmentId, 'payload_fetched', PROJECT);
+ assignmentStore.transitionAssignment(s1.assignmentId, 'submitted', PROJECT);
+
+ // Materialize and complete stage 2
+ const s2 = materializer.materializeNextLinearAssignment(
+ 'pipe-s3', 1, ['a', 'b', 'c'], 'output-1', PROJECT,
+ );
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'notified', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'acknowledged', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'payload_fetched', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'submitted', PROJECT);
+
+ // Materialize stage 3
+ const s3 = materializer.materializeNextLinearAssignment(
+ 'pipe-s3', 2, ['a', 'b', 'c'], 'output-2', PROJECT,
+ );
+ expect(s3).not.toBeNull();
+ expect(s3!.assignee).toBe('c');
+ expect(s3!.stage).toBe(3);
+ });
+
+ it('returns null when all stages are complete', () => {
+ materializer.materializePipeAssignments(
+ 'pipe-done', 'linear', ['alice', 'bob'], 'prompt', PROJECT,
+ );
+ // Complete stage 1
+ const assignments = assignmentStore.getAssignmentsByPipe('pipe-done', PROJECT);
+ assignmentStore.transitionAssignment(assignments[0].assignmentId, 'notified', PROJECT);
+ assignmentStore.transitionAssignment(assignments[0].assignmentId, 'acknowledged', PROJECT);
+ assignmentStore.transitionAssignment(assignments[0].assignmentId, 'payload_fetched', PROJECT);
+ assignmentStore.transitionAssignment(assignments[0].assignmentId, 'submitted', PROJECT);
+
+ // Complete stage 2
+ const s2 = materializer.materializeNextLinearAssignment(
+ 'pipe-done', 1, ['alice', 'bob'], 'output-1', PROJECT,
+ );
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'notified', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'acknowledged', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'payload_fetched', PROJECT);
+ assignmentStore.transitionAssignment(s2!.assignmentId, 'submitted', PROJECT);
+
+ // No stage 3 — should return null
+ const s3 = materializer.materializeNextLinearAssignment(
+ 'pipe-done', 2, ['alice', 'bob'], 'output-2', PROJECT,
+ );
+ expect(s3).toBeNull();
+ });
+});
+
+// ── materializePipeAssignments (merge) ──────────────────────────────────────
+
+describe('materializePipeAssignments — merge', () => {
+ it('creates fan-out assignments for all assignees except synthesizer', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-merge', 'merge', ['alice', 'bob', 'charlie'], 'Compare approaches', PROJECT,
+ );
+
+ // merge: last assignee is synthesizer, rest are fan-out
+ expect(results).toHaveLength(2);
+ expect(results[0].assignee).toBe('alice');
+ expect(results[0].role).toBe('fan-out');
+ expect(results[0].stageId).toBe('fan-out:alice');
+ expect(results[1].assignee).toBe('bob');
+ expect(results[1].role).toBe('fan-out');
+ expect(results[1].stageId).toBe('fan-out:bob');
+ });
+
+ it('does not create synthesizer assignment during initial materialization', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-merge2', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT,
+ );
+
+ // No assignment for charlie (synthesizer)
+ const assigneeNames = results.map(r => r.assignee);
+ expect(assigneeNames).not.toContain('charlie');
+ });
+});
+
+// ── materializePipeAssignments (merge-all) ──────────────────────────────────
+
+describe('materializePipeAssignments — merge-all', () => {
+ it('creates fan-out assignments for ALL assignees including synthesizer', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-mall', 'merge-all', ['alice', 'bob', 'charlie'], 'Everyone weighs in', PROJECT,
+ );
+
+ expect(results).toHaveLength(3);
+ expect(results[0].assignee).toBe('alice');
+ expect(results[1].assignee).toBe('bob');
+ expect(results[2].assignee).toBe('charlie');
+ results.forEach(r => {
+ expect(r.role).toBe('fan-out');
+ });
+ });
+});
+
+// ── materializePipeAssignments (explain / summarize) ────────────────────────
+
+describe('materializePipeAssignments — explain', () => {
+ it('creates fan-out assignments for all assignees (explain is merge-all style)', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-exp', 'explain', ['alice', 'bob'], 'Explain closures', PROJECT,
+ );
+
+ expect(results).toHaveLength(2);
+ expect(results[0].assignee).toBe('alice');
+ expect(results[1].assignee).toBe('bob');
+ });
+});
+
+describe('materializePipeAssignments — summarize', () => {
+ it('creates fan-out assignments for all assignees (summarize is merge-all style)', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-sum', 'summarize', ['alice', 'bob', 'charlie'], 'Summarize findings', PROJECT,
+ );
+
+ expect(results).toHaveLength(3);
+ });
+});
+
+// ── materializeSynthAssignment ──────────────────────────────────────────────
+
+describe('materializeSynthAssignment', () => {
+ it('creates a synth assignment with final role', () => {
+ const result = materializer.materializeSynthAssignment(
+ 'pipe-synth', 'merge', 'charlie', 'Synthesize: alice said X, bob said Y', PROJECT,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.assignee).toBe('charlie');
+ expect(result!.role).toBe('final');
+ expect(result!.stageId).toBe('synth');
+ expect(result!.stage).toBeUndefined();
+ });
+
+ it('stores the synthesis prompt as payload content', () => {
+ const synthBody = 'Combine outputs from alice and bob';
+ const result = materializer.materializeSynthAssignment(
+ 'pipe-synth2', 'merge', 'charlie', synthBody, PROJECT,
+ );
+
+ const payload = payloadStore.getPayload(result!.payloadId, PROJECT);
+ expect(payload!.content).toBe(synthBody);
+ });
+});
+
+// ── completeAssignment ──────────────────────────────────────────────────────
+
+describe('completeAssignment', () => {
+ it('transitions assignment to submitted', () => {
+ const result = materializer.materializeAssignment('pipe-comp', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Do something',
+ }, PROJECT);
+
+ // Walk through lifecycle
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+
+ const ok = materializer.completeAssignment(result!.assignmentId, PROJECT);
+ expect(ok).toBe(true);
+
+ // Verify assignment is in submitted status
+ const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(assignment!.status).toBe('submitted');
+ });
+
+ it('fast-forwards from assigned to submitted (supports submit-without-fetch)', () => {
+ const result = materializer.materializeAssignment('pipe-comp2', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Work',
+ }, PROJECT);
+
+ // completeAssignment fast-forwards through all intermediate states
+ // This supports the observed LLM pattern of submitting without calling pipe_read_output
+ const ok = materializer.completeAssignment(result!.assignmentId, PROJECT);
+ expect(ok).toBe(true);
+
+ const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(assignment!.status).toBe('submitted');
+ });
+});
+
+// ── transitionAssignmentStatus ──────────────────────────────────────────────
+
+describe('transitionAssignmentStatus', () => {
+ it('transitions through the full lifecycle', () => {
+ const result = materializer.materializeAssignment('pipe-trans', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Task',
+ }, PROJECT);
+
+ const id = result!.assignmentId;
+
+ const a1 = materializer.transitionAssignmentStatus(id, 'notified', PROJECT);
+ expect(a1).not.toBeNull();
+ expect(a1!.status).toBe('notified');
+
+ const a2 = materializer.transitionAssignmentStatus(id, 'acknowledged', PROJECT);
+ expect(a2!.status).toBe('acknowledged');
+
+ const a3 = materializer.transitionAssignmentStatus(id, 'payload_fetched', PROJECT);
+ expect(a3!.status).toBe('payload_fetched');
+
+ const a4 = materializer.transitionAssignmentStatus(id, 'submitted', PROJECT);
+ expect(a4!.status).toBe('submitted');
+ });
+
+ it('returns null for invalid transition', () => {
+ const result = materializer.materializeAssignment('pipe-invalid', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Task',
+ }, PROJECT);
+
+ // Can't go directly to payload_fetched from assigned
+ const a = materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+ expect(a).toBeNull();
+ });
+
+ it('returns null for non-existent assignment', () => {
+ const a = materializer.transitionAssignmentStatus('nonexistent', 'notified', PROJECT);
+ expect(a).toBeNull();
+ });
+});
+
+// ── cancelPipeAssignments ───────────────────────────────────────────────────
+
+describe('cancelPipeAssignments', () => {
+ it('cancels all active assignments for a pipe', () => {
+ // Create fan-out assignments
+ materializer.materializePipeAssignments(
+ 'pipe-cancel', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT,
+ );
+
+ const cancelled = materializer.cancelPipeAssignments('pipe-cancel', PROJECT);
+ expect(cancelled).toHaveLength(2); // alice and bob fan-outs
+
+ // Verify all are cancelled
+ const assignments = assignmentStore.getAssignmentsByPipe('pipe-cancel', PROJECT);
+ for (const a of assignments) {
+ expect(a.status).toBe('cancelled');
+ }
+ });
+
+ it('returns empty array when no active assignments exist', () => {
+ const cancelled = materializer.cancelPipeAssignments('nonexistent-pipe', PROJECT);
+ expect(cancelled).toHaveLength(0);
+ });
+
+ it('does not cancel already terminal assignments', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-partial-cancel', 'merge', ['alice', 'bob', 'charlie'], 'prompt', PROJECT,
+ );
+
+ // Complete alice's assignment
+ const aliceId = results[0].assignmentId;
+ assignmentStore.transitionAssignment(aliceId, 'notified', PROJECT);
+ assignmentStore.transitionAssignment(aliceId, 'acknowledged', PROJECT);
+ assignmentStore.transitionAssignment(aliceId, 'payload_fetched', PROJECT);
+ assignmentStore.transitionAssignment(aliceId, 'submitted', PROJECT);
+
+ // Cancel remaining — should only cancel bob's
+ const cancelled = materializer.cancelPipeAssignments('pipe-partial-cancel', PROJECT);
+ expect(cancelled).toHaveLength(1);
+
+ // Alice should still be submitted
+ const alice = assignmentStore.getAssignment(aliceId, PROJECT);
+ expect(alice!.status).toBe('submitted');
+ });
+});
+
+// ── Role derivation ─────────────────────────────────────────────────────────
+
+describe('role derivation', () => {
+ it('handoff action maps to stage-output role', () => {
+ const result = materializer.materializeAssignment('pipe-role1', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'prompt',
+ }, PROJECT);
+
+ expect(result!.role).toBe('stage-output');
+ });
+
+ it('fan-out-request action maps to fan-out role', () => {
+ const result = materializer.materializeAssignment('pipe-role2', 'merge', {
+ type: 'fan-out-request',
+ targetAssignee: 'bob',
+ body: 'prompt',
+ }, PROJECT);
+
+ expect(result!.role).toBe('fan-out');
+ });
+
+ it('synth-request action maps to final role', () => {
+ const result = materializer.materializeAssignment('pipe-role3', 'merge', {
+ type: 'synth-request',
+ targetAssignee: 'charlie',
+ body: 'synthesize',
+ }, PROJECT);
+
+ expect(result!.role).toBe('final');
+ });
+});
diff --git a/src/apps/chat/services/pipe-assignment-materializer.ts b/src/apps/chat/services/pipe-assignment-materializer.ts
new file mode 100644
index 0000000..1573df6
--- /dev/null
+++ b/src/apps/chat/services/pipe-assignment-materializer.ts
@@ -0,0 +1,260 @@
+import * as assignmentStore from './assignment-store.js';
+import * as payloadStore from './payload-store.js';
+import type { PipeMode, AssignmentStatus } from '../types.js';
+
+/** Result of materializing assignments for a pipe action. */
+export interface MaterializedAssignment {
+ assignmentId: string;
+ payloadId: string;
+ stageId: string;
+ assignee: string;
+ role: 'stage-output' | 'fan-out' | 'final';
+ stage?: number;
+ notification: assignmentStore.AssignmentNotification;
+}
+
+/**
+ * Materialize an assignment + payload for a pipe action.
+ * Called by the reducer orchestration when an action is emitted.
+ * Creates the payload first (content), then the assignment (referencing payloadId).
+ * Returns the materialized assignment or null if creation failed.
+ */
+export function materializeAssignment(
+ pipeId: string,
+ mode: PipeMode,
+ action: {
+ type: 'handoff' | 'fan-out-request' | 'synth-request';
+ targetAssignee: string;
+ stage?: number;
+ body: string;
+ },
+ projectId: string | null,
+): MaterializedAssignment | null {
+ // Derive role from action type
+ const role = actionTypeToRole(action.type);
+ const stageId = assignmentStore.deriveStageId(mode, role, {
+ stage: action.stage,
+ assignee: action.targetAssignee,
+ });
+
+ // Create payload first
+ const payloadResult = payloadStore.createPayload(pipeId, stageId, action.body, projectId, {
+ sourceStage: action.stage ? action.stage - 1 : undefined,
+ });
+ if (!payloadResult.ok || !payloadResult.payload) return null;
+
+ // Create assignment referencing the payload
+ const assignResult = assignmentStore.createAssignment(
+ pipeId, stageId, payloadResult.payload.payloadId,
+ action.targetAssignee, role, projectId,
+ { stage: action.stage },
+ );
+ if (!assignResult.ok || !assignResult.assignment) return null;
+
+ const a = assignResult.assignment;
+ return {
+ assignmentId: a.assignmentId,
+ payloadId: payloadResult.payload.payloadId,
+ stageId,
+ assignee: a.assignee,
+ role,
+ stage: a.stage,
+ notification: {
+ assignmentId: a.assignmentId,
+ pipeId,
+ stageId,
+ role,
+ stage: a.stage,
+ attempt: a.attempt,
+ payloadId: payloadResult.payload.payloadId,
+ },
+ };
+}
+
+/**
+ * Materialize all assignments for a newly created pipe.
+ * For merge/merge-all modes, creates fan-out assignments upfront.
+ * For linear, only the first stage assignment is created (rest on demand).
+ */
+export function materializePipeAssignments(
+ pipeId: string,
+ mode: PipeMode,
+ assignees: string[],
+ prompt: string,
+ projectId: string | null,
+): MaterializedAssignment[] {
+ const results: MaterializedAssignment[] = [];
+
+ if (mode === 'linear') {
+ // Linear: only materialize stage 1 — subsequent stages are created on submission
+ const result = materializeAssignment(pipeId, mode, {
+ type: 'handoff',
+ targetAssignee: assignees[0],
+ stage: 1,
+ body: prompt,
+ }, projectId);
+ if (result) results.push(result);
+ } else {
+ // Merge / merge-all / explain / summarize: materialize all fan-out assignments
+ const isMergeAll = mode === 'merge-all' || mode === 'explain' || mode === 'summarize';
+ const fanOutAssignees = isMergeAll ? assignees : assignees.slice(0, -1);
+
+ for (const assignee of fanOutAssignees) {
+ const result = materializeAssignment(pipeId, mode, {
+ type: 'fan-out-request',
+ targetAssignee: assignee,
+ body: prompt,
+ }, projectId);
+ if (result) results.push(result);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Transition an assignment through the delivery lifecycle.
+ * Returns the updated assignment or null if transition failed.
+ */
+export function transitionAssignmentStatus(
+ assignmentId: string,
+ newStatus: AssignmentStatus,
+ projectId: string | null,
+): assignmentStore.Assignment | null {
+ const result = assignmentStore.transitionAssignment(assignmentId, newStatus, projectId);
+ return result.ok ? (result.assignment ?? null) : null;
+}
+
+/**
+ * Handle submission: walk assignment through any needed intermediate transitions
+ * to reach 'submitted', then archive the payload.
+ * Handles cases where assignee submits without explicit fetch (legacy path).
+ */
+export function completeAssignment(
+ assignmentId: string,
+ projectId: string | null,
+): boolean {
+ const assignment = assignmentStore.getAssignment(assignmentId, projectId);
+ if (!assignment) return false;
+
+ // Walk through intermediate states if needed (legacy path: submit without fetch)
+ const stepsToSubmitted: import('../types.js').AssignmentStatus[] = [
+ 'notified', 'acknowledged', 'payload_fetched', 'submitted',
+ ];
+ const currentIdx = stepsToSubmitted.indexOf(assignment.status);
+ const targetIdx = stepsToSubmitted.indexOf('submitted');
+
+ if (assignment.status === 'assigned') {
+ // Fast-forward from assigned through all intermediate states
+ for (const step of stepsToSubmitted) {
+ const r = assignmentStore.transitionAssignment(assignmentId, step, projectId);
+ if (!r.ok) return false;
+ }
+ } else if (currentIdx >= 0 && currentIdx < targetIdx) {
+ // Walk from current position to submitted
+ for (let i = currentIdx + 1; i <= targetIdx; i++) {
+ const r = assignmentStore.transitionAssignment(assignmentId, stepsToSubmitted[i], projectId);
+ if (!r.ok) return false;
+ }
+ } else if (assignment.status !== 'submitted') {
+ // Direct transition attempt
+ const r = assignmentStore.transitionAssignment(assignmentId, 'submitted', projectId);
+ if (!r.ok) return false;
+ }
+
+ payloadStore.archivePayload(assignment.payloadId, projectId);
+ return true;
+}
+
+/**
+ * Cancel all assignments for a pipe (on pipe cancel/failure).
+ * Also archives all associated payloads.
+ * Returns the cancelled assignmentIds.
+ */
+export function cancelPipeAssignments(
+ pipeId: string,
+ projectId: string | null,
+): string[] {
+ const cancelled = assignmentStore.cancelPipeAssignments(pipeId, projectId);
+ payloadStore.archivePipePayloads(pipeId, projectId);
+ return cancelled;
+}
+
+/**
+ * Materialize the next assignment when a linear stage completes.
+ * Called after a stage submission to create the assignment for the next stage.
+ */
+export function materializeNextLinearAssignment(
+ pipeId: string,
+ completedStage: number,
+ assignees: string[],
+ body: string,
+ projectId: string | null,
+): MaterializedAssignment | null {
+ const nextStage = completedStage + 1;
+ if (nextStage > assignees.length) return null;
+
+ return materializeAssignment(pipeId, 'linear', {
+ type: 'handoff',
+ targetAssignee: assignees[nextStage - 1],
+ stage: nextStage,
+ body,
+ }, projectId);
+}
+
+/**
+ * Materialize the synthesizer assignment after all fan-outs complete.
+ */
+export function materializeSynthAssignment(
+ pipeId: string,
+ mode: PipeMode,
+ synthesizer: string,
+ synthBody: string,
+ projectId: string | null,
+): MaterializedAssignment | null {
+ return materializeAssignment(pipeId, mode, {
+ type: 'synth-request',
+ targetAssignee: synthesizer,
+ body: synthBody,
+ }, projectId);
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+function actionTypeToRole(
+ actionType: 'handoff' | 'fan-out-request' | 'synth-request',
+): 'stage-output' | 'fan-out' | 'final' {
+ if (actionType === 'synth-request') return 'final';
+ if (actionType === 'fan-out-request') return 'fan-out';
+ return 'stage-output';
+}
+
+/**
+ * Get the assignment notification envelope for an existing assignment.
+ * Used for re-delivery after reconnect.
+ */
+export function getAssignmentNotification(
+ assignmentId: string,
+ projectId: string | null,
+): assignmentStore.AssignmentNotification | null {
+ const assignment = assignmentStore.getAssignment(assignmentId, projectId);
+ if (!assignment) return null;
+ return assignmentStore.toNotification(assignment);
+}
+
+/**
+ * Get active (non-terminal) assignments for a participant on a specific pipe.
+ * Used during submission to find the assignment to complete.
+ */
+export function getActiveAssignmentsForParticipant(
+ assignee: string,
+ pipeId: string,
+ projectId: string | null,
+): assignmentStore.Assignment[] {
+ const all = assignmentStore.getAssignmentsByPipe(pipeId, projectId);
+ return all.filter((a: assignmentStore.Assignment) => a.assignee === assignee && !isTerminal(a.status));
+}
+
+function isTerminal(status: import('../types.js').AssignmentStatus): boolean {
+ return ['submitted', 'expired', 'reassigned', 'superseded', 'cancelled'].includes(status);
+}
diff --git a/src/apps/chat/services/pipe-assignment-queries.ts b/src/apps/chat/services/pipe-assignment-queries.ts
new file mode 100644
index 0000000..29d8fdd
--- /dev/null
+++ b/src/apps/chat/services/pipe-assignment-queries.ts
@@ -0,0 +1,53 @@
+/**
+ * Pipe assignment query functions for lease-aware authorization.
+ *
+ * Re-exports getAssignmentsForParticipant from pipe-store (authoritative)
+ * and adds getAssignmentForPipe for single-pipe lookups.
+ * Used by pipe_list_assignments and pipe_get_assignment MCP tools/REST endpoints.
+ */
+import type { PipeMode, PipeStatus } from '../types.js';
+import type { PipeSlot } from './pipe-store.js';
+import * as pipeStore from './pipe-store.js';
+
+// Re-export the authoritative type and list function from pipe-store
+export type { ParticipantAssignment } from './pipe-store.js';
+export { getAssignmentsForParticipant } from './pipe-store.js';
+
+/** Get a single assignment's details for a participant on a specific pipe.
+ * Returns the most relevant slot (leased > pending > submitted). */
+export function getAssignmentForPipe(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): pipeStore.ParticipantAssignment | undefined {
+ const pipe = pipeStore.getPipe(pipeId, projectId);
+ if (!pipe) return undefined;
+ const slots = pipe.slots.get(assignee);
+ if (!slots || slots.length === 0) return undefined;
+
+ const lease = pipeStore.getActiveLease(assignee, projectId);
+ const sorted = [...slots].sort((a, b) => {
+ const order: Record = { leased: 0, pending: 1, submitted: 2 };
+ return (order[a.status] ?? 3) - (order[b.status] ?? 3);
+ });
+
+ const slot = sorted[0];
+ const isLeasedSlot = lease?.pipeId === pipeId
+ && lease.slotRole === slot.role
+ && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined));
+
+ let leaseStatus: pipeStore.ParticipantAssignment['leaseStatus'] = 'none';
+ let deadline: string | null = null;
+ let grantedAt: string | null = null;
+
+ if (isLeasedSlot && lease) {
+ leaseStatus = pipeStore.isLeaseExpired(lease) ? 'expired' : 'active';
+ deadline = lease.deadline;
+ grantedAt = lease.grantedAt;
+ }
+
+ return {
+ pipeId, mode: pipe.mode, role: slot.role, stage: slot.stage,
+ slotStatus: slot.status, leaseStatus, deadline, grantedAt, pipeStatus: pipe.status,
+ };
+}
diff --git a/src/apps/chat/services/pipe-delivery.test.ts b/src/apps/chat/services/pipe-delivery.test.ts
new file mode 100644
index 0000000..3b45f6d
--- /dev/null
+++ b/src/apps/chat/services/pipe-delivery.test.ts
@@ -0,0 +1,414 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as delivery from './pipe-delivery.js';
+import { createTestClock } from './clock.js';
+
+const PROJECT = 'test-project';
+
+beforeEach(() => {
+ delivery._resetForTest();
+});
+
+// ── Delivery lifecycle ──────────────────────────────────────────────────────
+
+describe('delivery state machine', () => {
+ it('creates a delivery record in assigned state', () => {
+ const record = delivery.createDelivery('pipe-1', 'alice', 'handoff', 'full payload', PROJECT, 1);
+ expect(record.state).toBe('assigned');
+ expect(record.pipeId).toBe('pipe-1');
+ expect(record.assignee).toBe('alice');
+ expect(record.stage).toBe(1);
+ expect(record.role).toBe('handoff');
+ expect(record.payload).toBe('full payload');
+ expect(record.notifyAttempts).toBe(0);
+ expect(record.notifiedAt).toBeNull();
+ expect(record.fetchedAt).toBeNull();
+ expect(record.submittedAt).toBeNull();
+ });
+
+ it('transitions assigned → notified on recordNotification', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ const ok = delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-1', 'alice', PROJECT);
+ expect(record?.state).toBe('notified');
+ expect(record?.notifyAttempts).toBe(1);
+ expect(record?.notifiedAt).toBeTruthy();
+ expect(record?.lastNotifyAttemptAt).toBeTruthy();
+ });
+
+ it('transitions notified → fetched on recordFetch', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ const ok = delivery.recordFetch('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-1', 'alice', PROJECT);
+ expect(record?.state).toBe('fetched');
+ expect(record?.fetchedAt).toBeTruthy();
+ });
+
+ it('transitions fetched → submitted on recordSubmission', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.recordFetch('pipe-1', 'alice', PROJECT);
+ const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-1', 'alice', PROJECT);
+ expect(record?.state).toBe('submitted');
+ expect(record?.submittedAt).toBeTruthy();
+ });
+
+ it('allows direct notified → submitted (skip fetch)', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-1', 'alice', PROJECT);
+ expect(record?.state).toBe('submitted');
+ });
+
+ it('allows direct assigned → submitted (fire and forget)', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+ });
+
+ it('rejects notification after submission', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+
+ const ok = delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(false);
+ });
+
+ it('rejects fetch after cancellation', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.cancelDelivery('pipe-1', 'alice', PROJECT);
+
+ const ok = delivery.recordFetch('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(false);
+ });
+
+ it('rejects submission after expiry', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.expireDelivery('pipe-1', 'alice', PROJECT);
+
+ const ok = delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(false);
+ });
+
+ it('returns false for unknown delivery', () => {
+ expect(delivery.recordNotification('pipe-999', 'nobody', PROJECT)).toBe(false);
+ expect(delivery.recordFetch('pipe-999', 'nobody', PROJECT)).toBe(false);
+ expect(delivery.recordSubmission('pipe-999', 'nobody', PROJECT)).toBe(false);
+ });
+});
+
+// ── Cancellation ────────────────────────────────────────────────────────────
+
+describe('delivery cancellation', () => {
+ it('cancelDelivery marks record as cancelled', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+
+ const ok = delivery.cancelDelivery('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('cancelled');
+ });
+
+ it('cancelDelivery does not cancel submitted deliveries', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+
+ const ok = delivery.cancelDelivery('pipe-1', 'alice', PROJECT);
+ expect(ok).toBe(false);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('submitted');
+ });
+
+ it('cancelAllDeliveries cancels all non-submitted deliveries for a pipe', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.createDelivery('pipe-1', 'bob', 'handoff', 'payload', PROJECT, 2);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ delivery.recordNotification('pipe-1', 'bob', PROJECT);
+
+ delivery.cancelAllDeliveries('pipe-1', PROJECT);
+
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('submitted'); // preserved
+ expect(delivery.getDelivery('pipe-1', 'bob', PROJECT)?.state).toBe('cancelled');
+ });
+
+ it('cancelDeliveriesForAssignee cancels all deliveries for a participant', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.createDelivery('pipe-2', 'alice', 'fan-out-request', 'payload', PROJECT);
+ delivery.createDelivery('pipe-1', 'bob', 'handoff', 'payload', PROJECT, 2);
+
+ delivery.cancelDeliveriesForAssignee('alice', PROJECT);
+
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('cancelled');
+ expect(delivery.getDelivery('pipe-2', 'alice', PROJECT)?.state).toBe('cancelled');
+ expect(delivery.getDelivery('pipe-1', 'bob', PROJECT)?.state).toBe('assigned'); // untouched
+ });
+});
+
+// ── Re-notify logic ─────────────────────────────────────────────────────────
+
+describe('re-notify logic', () => {
+ it('needsRenotify returns false before notification', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false);
+ });
+
+ it('needsRenotify returns false immediately after notification (interval not elapsed)', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false);
+ });
+
+ it('needsRenotify returns true after interval has elapsed', () => {
+ const clock = createTestClock();
+ delivery.setDeliveryClock(clock);
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ renotifyIntervalMs: 10_000,
+ });
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+
+ // Advance past the interval
+ clock.advance(15_000);
+ expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(true);
+ });
+
+ it('needsRenotify returns false after fetch (acked)', () => {
+ const clock = createTestClock();
+ delivery.setDeliveryClock(clock);
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ renotifyIntervalMs: 10_000,
+ });
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ delivery.recordFetch('pipe-1', 'alice', PROJECT);
+
+ clock.advance(15_000);
+ expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false);
+ });
+
+ it('needsRenotify returns false when max attempts reached', () => {
+ const clock = createTestClock();
+ delivery.setDeliveryClock(clock);
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ maxNotifyAttempts: 2,
+ renotifyIntervalMs: 5_000,
+ });
+
+ // First notification
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ clock.advance(6_000);
+
+ // Second notification (hits max)
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ clock.advance(6_000);
+
+ expect(delivery.needsRenotify('pipe-1', 'alice', PROJECT)).toBe(false);
+ });
+
+ it('startRenotifyTimer fires callback after interval', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ renotifyIntervalMs: 5_000,
+ maxNotifyAttempts: 3,
+ });
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback);
+
+ vi.advanceTimersByTime(5_000);
+ expect(callback).toHaveBeenCalledWith('pipe-1', 'alice', PROJECT);
+
+ vi.useRealTimers();
+ });
+
+ it('startRenotifyTimer does not fire after fetch', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ renotifyIntervalMs: 5_000,
+ });
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback);
+
+ // Fetch before timer fires — should cancel it
+ delivery.recordFetch('pipe-1', 'alice', PROJECT);
+
+ vi.advanceTimersByTime(10_000);
+ expect(callback).not.toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+
+ it('expireDelivery is called when attempts exhausted in handleRenotifyTick', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ maxNotifyAttempts: 1,
+ renotifyIntervalMs: 5_000,
+ });
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-1', 'alice', PROJECT, callback);
+
+ vi.advanceTimersByTime(5_000);
+
+ // Callback should NOT have been called — attempts exhausted, delivery expired
+ expect(callback).not.toHaveBeenCalled();
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.state).toBe('expired');
+
+ vi.useRealTimers();
+ });
+});
+
+// ── Payload retrieval ───────────────────────────────────────────────────────
+
+describe('payload retrieval', () => {
+ it('getDeliveryPayload returns the stored payload', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'this is the full payload', PROJECT, 1);
+ expect(delivery.getDeliveryPayload('pipe-1', 'alice', PROJECT)).toBe('this is the full payload');
+ });
+
+ it('getDeliveryPayload returns undefined for unknown delivery', () => {
+ expect(delivery.getDeliveryPayload('pipe-999', 'nobody', PROJECT)).toBeUndefined();
+ });
+});
+
+// ── Active delivery queries ─────────────────────────────────────────────────
+
+describe('active delivery queries', () => {
+ it('getActiveDeliveries returns non-terminal records', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'p1', PROJECT, 1);
+ delivery.createDelivery('pipe-1', 'bob', 'handoff', 'p2', PROJECT, 2);
+ delivery.createDelivery('pipe-1', 'carol', 'handoff', 'p3', PROJECT, 3);
+
+ // Submit alice, cancel carol
+ delivery.recordSubmission('pipe-1', 'alice', PROJECT);
+ delivery.cancelDelivery('pipe-1', 'carol', PROJECT);
+
+ const active = delivery.getActiveDeliveries('pipe-1', PROJECT);
+ expect(active).toHaveLength(1);
+ expect(active[0].assignee).toBe('bob');
+ });
+});
+
+// ── Compact notification formatting ─────────────────────────────────────────
+
+describe('compact notification formatting', () => {
+ it('formats a linear handoff notification', () => {
+ const notification = delivery.formatCompactNotification(
+ 'abc123', 'linear', 'handoff', 'alice', 3, 2,
+ );
+ expect(notification.body).toContain('#pipe-abc123');
+ expect(notification.body).toContain('[linear | stage 2/3 | @alice]');
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")');
+ expect(notification.body).toContain('pipe_submit(pipeId="abc123"');
+ expect(notification.body).toContain('Your output passes to the next stage.');
+ // Should NOT contain the full prompt text
+ expect(notification.pipe.pipeId).toBe('abc123');
+ expect(notification.pipe.role).toBe('handoff');
+ expect(notification.pipe.stage).toBe(2);
+ });
+
+ it('formats a final stage notification', () => {
+ const notification = delivery.formatCompactNotification(
+ 'abc123', 'linear', 'handoff', 'carol', 3, 3,
+ );
+ expect(notification.body).toContain('Final stage');
+ expect(notification.body).toContain('stage 3/3');
+ });
+
+ it('formats a fan-out-request notification', () => {
+ const notification = delivery.formatCompactNotification(
+ 'abc123', 'merge-all', 'fan-out-request', 'bob', 3,
+ );
+ expect(notification.body).toContain('[merge-all | fan-out | @bob]');
+ expect(notification.body).toContain('independent analysis');
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")');
+ });
+
+ it('formats a synth-request notification', () => {
+ const notification = delivery.formatCompactNotification(
+ 'abc123', 'merge', 'synth-request', 'synth', 3,
+ );
+ expect(notification.body).toContain('[merge | synthesizer | @synth]');
+ expect(notification.body).toContain('Synthesize');
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")');
+ });
+
+});
+
+// ── Clock injection ─────────────────────────────────────────────────────────
+
+describe('injectable clock', () => {
+ it('uses injected clock for timestamps', () => {
+ const clock = createTestClock(1700000000000); // fixed time
+ delivery.setDeliveryClock(clock);
+
+ const record = delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+ expect(record.assignedAt).toBe(new Date(1700000000000).toISOString());
+
+ clock.advance(5000);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ const updated = delivery.getDelivery('pipe-1', 'alice', PROJECT);
+ expect(updated?.notifiedAt).toBe(new Date(1700000005000).toISOString());
+ });
+});
+
+// ── Multiple re-notification increments ─────────────────────────────────────
+
+describe('re-notification counting', () => {
+ it('increments notifyAttempts on each recordNotification call', () => {
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1, {
+ maxNotifyAttempts: 5,
+ });
+
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(1);
+
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(2);
+
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifyAttempts).toBe(3);
+ });
+
+ it('preserves first notifiedAt on subsequent notifications', () => {
+ const clock = createTestClock();
+ delivery.setDeliveryClock(clock);
+
+ delivery.createDelivery('pipe-1', 'alice', 'handoff', 'payload', PROJECT, 1);
+
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ const firstNotifiedAt = delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifiedAt;
+
+ clock.advance(10_000);
+ delivery.recordNotification('pipe-1', 'alice', PROJECT);
+ const secondNotifiedAt = delivery.getDelivery('pipe-1', 'alice', PROJECT)?.notifiedAt;
+
+ // First notifiedAt is preserved — only lastNotifyAttemptAt changes
+ expect(secondNotifiedAt).toBe(firstNotifiedAt);
+ expect(delivery.getDelivery('pipe-1', 'alice', PROJECT)?.lastNotifyAttemptAt).not.toBe(firstNotifiedAt);
+ });
+});
diff --git a/src/apps/chat/services/pipe-delivery.ts b/src/apps/chat/services/pipe-delivery.ts
new file mode 100644
index 0000000..683f879
--- /dev/null
+++ b/src/apps/chat/services/pipe-delivery.ts
@@ -0,0 +1,439 @@
+import type { Clock } from './clock.js';
+import { systemClock } from './clock.js';
+import type { PipeMode, PipeRole } from '../types.js';
+
+// ── Delivery state machine ──────────────────────────────────────────────────
+
+/** Delivery lifecycle for a single stage assignment.
+ * assigned → notified → fetched → submitted
+ * ↓ ↓
+ * (re-notify) (submitted)
+ * ↓
+ * expired / cancelled */
+export type DeliveryState =
+ | 'assigned' // lease granted, notification not yet sent
+ | 'notified' // compact notification delivered to PTY
+ | 'fetched' // assignee called pipe_read_output (implicit ack)
+ | 'submitted' // assignee submitted via pipe_submit
+ | 'expired' // re-notify attempts exhausted or deadline passed
+ | 'cancelled'; // pipe cancelled or assignee reassigned
+
+export interface DeliveryRecord {
+ pipeId: string;
+ assignee: string;
+ stage?: number;
+ role: string; // 'handoff' | 'fan-out-request' | 'synth-request'
+ state: DeliveryState;
+ /** Full payload body generated by the reducer — stored here for fetch retrieval. */
+ payload: string;
+ /** Number of PTY notification attempts so far. */
+ notifyAttempts: number;
+ /** Maximum notification attempts before escalating / expiring. */
+ maxNotifyAttempts: number;
+ /** Milliseconds between re-notify attempts. */
+ renotifyIntervalMs: number;
+ // Timestamps (ISO)
+ assignedAt: string;
+ notifiedAt: string | null;
+ fetchedAt: string | null;
+ submittedAt: string | null;
+ lastNotifyAttemptAt: string | null;
+}
+
+// ── Configuration ───────────────────────────────────────────────────────────
+
+export const DEFAULT_MAX_NOTIFY_ATTEMPTS = 3;
+export const DEFAULT_RENOTIFY_INTERVAL_MS = 30_000; // 30 seconds
+
+export interface DeliveryConfig {
+ maxNotifyAttempts?: number;
+ renotifyIntervalMs?: number;
+}
+
+// ── Storage ─────────────────────────────────────────────────────────────────
+
+// Key: "projectId:pipeId:assignee"
+const deliveryRecords = new Map();
+
+// Re-notify timers: same key → NodeJS.Timeout
+const renotifyTimers = new Map>();
+
+function deliveryKey(pipeId: string, assignee: string, projectId: string | null): string {
+ return `${projectId ?? '__none__'}:${pipeId}:${assignee}`;
+}
+
+// ── Injectable clock ────────────────────────────────────────────────────────
+
+let _clock: Clock = systemClock;
+
+export function setDeliveryClock(clock: Clock): void {
+ _clock = clock;
+}
+
+// ── Delivery lifecycle ──────────────────────────────────────────────────────
+
+/** Create a delivery record when a lease is granted and the reducer action is ready. */
+export function createDelivery(
+ pipeId: string,
+ assignee: string,
+ role: string,
+ payload: string,
+ projectId: string | null,
+ stage?: number,
+ config?: DeliveryConfig,
+): DeliveryRecord {
+ const key = deliveryKey(pipeId, assignee, projectId);
+
+ const record: DeliveryRecord = {
+ pipeId,
+ assignee,
+ stage,
+ role,
+ state: 'assigned',
+ payload,
+ notifyAttempts: 0,
+ maxNotifyAttempts: config?.maxNotifyAttempts ?? DEFAULT_MAX_NOTIFY_ATTEMPTS,
+ renotifyIntervalMs: config?.renotifyIntervalMs ?? DEFAULT_RENOTIFY_INTERVAL_MS,
+ assignedAt: _clock.isoNow(),
+ notifiedAt: null,
+ fetchedAt: null,
+ submittedAt: null,
+ lastNotifyAttemptAt: null,
+ };
+
+ deliveryRecords.set(key, record);
+ return record;
+}
+
+/** Record that the compact notification was delivered to PTY.
+ * Returns false if the delivery is not in a valid state for notification. */
+export function recordNotification(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return false;
+ if (record.state === 'submitted' || record.state === 'expired' || record.state === 'cancelled') {
+ return false;
+ }
+
+ const now = _clock.isoNow();
+ record.state = 'notified';
+ record.notifyAttempts += 1;
+ record.notifiedAt = record.notifiedAt ?? now;
+ record.lastNotifyAttemptAt = now;
+ return true;
+}
+
+/** Record that the assignee fetched the payload (called when pipe_read_output is invoked).
+ * This serves as an implicit acknowledgment. */
+export function recordFetch(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return false;
+ if (record.state === 'submitted' || record.state === 'expired' || record.state === 'cancelled') {
+ return false;
+ }
+
+ record.state = 'fetched';
+ record.fetchedAt = _clock.isoNow();
+
+ // Cancel any pending re-notify timer — assignee is alive
+ cancelRenotifyTimer(pipeId, assignee, projectId);
+ return true;
+}
+
+/** Record that the assignee submitted their output. */
+export function recordSubmission(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return false;
+ if (record.state === 'expired' || record.state === 'cancelled') {
+ return false;
+ }
+
+ record.state = 'submitted';
+ record.submittedAt = _clock.isoNow();
+
+ cancelRenotifyTimer(pipeId, assignee, projectId);
+ return true;
+}
+
+/** Mark a delivery as cancelled (pipe cancelled or assignee reassigned). */
+export function cancelDelivery(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return false;
+ if (record.state === 'submitted') return false; // already done
+
+ record.state = 'cancelled';
+ cancelRenotifyTimer(pipeId, assignee, projectId);
+ return true;
+}
+
+/** Mark a delivery as expired (re-notify attempts exhausted). */
+export function expireDelivery(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return false;
+ if (record.state === 'submitted' || record.state === 'cancelled') return false;
+
+ record.state = 'expired';
+ cancelRenotifyTimer(pipeId, assignee, projectId);
+ return true;
+}
+
+// ── Re-notify logic ─────────────────────────────────────────────────────────
+
+export type RenotifyCallback = (pipeId: string, assignee: string, projectId: string | null) => void;
+
+/** Start the re-notify timer for a delivery.
+ * If the assignee hasn't fetched the payload within renotifyIntervalMs,
+ * the callback fires to re-deliver the notification. */
+export function startRenotifyTimer(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+ callback: RenotifyCallback,
+): void {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return;
+ if (record.state !== 'notified') return;
+
+ const key = deliveryKey(pipeId, assignee, projectId);
+
+ // Clear any existing timer
+ cancelRenotifyTimer(pipeId, assignee, projectId);
+
+ const timer = setTimeout(() => {
+ renotifyTimers.delete(key);
+ handleRenotifyTick(pipeId, assignee, projectId, callback);
+ }, record.renotifyIntervalMs);
+
+ // Don't prevent process exit
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
+
+ renotifyTimers.set(key, timer);
+}
+
+/** Internal: handle a re-notify tick. Checks whether to re-notify or expire. */
+function handleRenotifyTick(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+ callback: RenotifyCallback,
+): void {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record) return;
+
+ // Only re-notify if still in 'notified' state (not fetched, submitted, cancelled, etc.)
+ if (record.state !== 'notified') return;
+
+ if (record.notifyAttempts >= record.maxNotifyAttempts) {
+ // Exhausted — expire this delivery
+ expireDelivery(pipeId, assignee, projectId);
+ return;
+ }
+
+ // Fire the re-notify callback (caller re-delivers the compact notification)
+ callback(pipeId, assignee, projectId);
+}
+
+function cancelRenotifyTimer(pipeId: string, assignee: string, projectId: string | null): void {
+ const key = deliveryKey(pipeId, assignee, projectId);
+ const timer = renotifyTimers.get(key);
+ if (timer) {
+ clearTimeout(timer);
+ renotifyTimers.delete(key);
+ }
+}
+
+// ── Queries ─────────────────────────────────────────────────────────────────
+
+/** Get the delivery record for a specific assignment. */
+export function getDelivery(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): DeliveryRecord | undefined {
+ return deliveryRecords.get(deliveryKey(pipeId, assignee, projectId));
+}
+
+/** Get the stored payload for a delivery (used by fetch surface). */
+export function getDeliveryPayload(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): string | undefined {
+ return getDelivery(pipeId, assignee, projectId)?.payload;
+}
+
+/** Check if a delivery needs re-notification (notified but not fetched, timer-eligible). */
+export function needsRenotify(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): boolean {
+ const record = getDelivery(pipeId, assignee, projectId);
+ if (!record || record.state !== 'notified') return false;
+ if (record.notifyAttempts >= record.maxNotifyAttempts) return false;
+
+ // Check if enough time has passed since last notification attempt
+ if (!record.lastNotifyAttemptAt) return false;
+ const elapsed = _clock.now() - new Date(record.lastNotifyAttemptAt).getTime();
+ return elapsed >= record.renotifyIntervalMs;
+}
+
+/** Get all active (non-terminal) delivery records for a pipe. */
+export function getActiveDeliveries(
+ pipeId: string,
+ projectId: string | null,
+): DeliveryRecord[] {
+ const results: DeliveryRecord[] = [];
+ const prefix = `${projectId ?? '__none__'}:${pipeId}:`;
+ for (const [key, record] of deliveryRecords) {
+ if (key.startsWith(prefix) && record.state !== 'submitted' && record.state !== 'expired' && record.state !== 'cancelled') {
+ results.push(record);
+ }
+ }
+ return results;
+}
+
+/** Cancel all active deliveries for a pipe (called when pipe is cancelled/failed). */
+export function cancelAllDeliveries(pipeId: string, projectId: string | null): void {
+ const prefix = `${projectId ?? '__none__'}:${pipeId}:`;
+ for (const [key, record] of deliveryRecords) {
+ if (key.startsWith(prefix) && record.state !== 'submitted') {
+ record.state = 'cancelled';
+ // Cancel associated timer
+ const timer = renotifyTimers.get(key);
+ if (timer) {
+ clearTimeout(timer);
+ renotifyTimers.delete(key);
+ }
+ }
+ }
+}
+
+/** Cancel all deliveries for a specific assignee across all pipes (called when participant leaves). */
+export function cancelDeliveriesForAssignee(assignee: string, projectId: string | null): void {
+ const suffix = `:${assignee}`;
+ const prefix = `${projectId ?? '__none__'}:`;
+ for (const [key, record] of deliveryRecords) {
+ if (key.startsWith(prefix) && key.endsWith(suffix) && record.state !== 'submitted') {
+ record.state = 'cancelled';
+ const timer = renotifyTimers.get(key);
+ if (timer) {
+ clearTimeout(timer);
+ renotifyTimers.delete(key);
+ }
+ }
+ }
+}
+
+// ── Compact notification formatting ─────────────────────────────────────────
+
+export interface CompactNotification {
+ /** The compact text to inject into PTY. */
+ body: string;
+ /** Pipe metadata for the message envelope. */
+ pipe: {
+ pipeId: string;
+ mode: PipeMode;
+ role: PipeRole;
+ stage?: number;
+ targetAssignee: string;
+ };
+}
+
+/** Format a compact assignment notification for PTY delivery.
+ * This replaces the full payload body — the LLM must fetch the full content
+ * via pipe_read_output after receiving this notification. */
+export function formatCompactNotification(
+ pipeId: string,
+ mode: PipeMode,
+ actionType: 'handoff' | 'fan-out-request' | 'synth-request',
+ targetAssignee: string,
+ totalStages: number,
+ stage?: number,
+): CompactNotification {
+ const roleMap: Record = {
+ 'handoff': 'handoff',
+ 'fan-out-request': 'fan-out-request',
+ 'synth-request': 'synth-request',
+ };
+ const role = roleMap[actionType];
+
+ let header: string;
+ let guidance: string;
+
+ if (actionType === 'handoff') {
+ const isLast = stage === totalStages;
+ header = `#pipe-${pipeId} [linear | stage ${stage}/${totalStages} | @${targetAssignee}]`;
+ guidance = isLast
+ ? 'Final stage — your response goes to the user.'
+ : 'Your output passes to the next stage.';
+ } else if (actionType === 'fan-out-request') {
+ header = `#pipe-${pipeId} [${mode} | fan-out | @${targetAssignee}]`;
+ guidance = 'Provide your independent analysis. Other participants answer in parallel.';
+ } else {
+ header = `#pipe-${pipeId} [${mode} | synthesizer | @${targetAssignee}]`;
+ guidance = 'Synthesize the fan-out outputs into a unified response.';
+ }
+
+ const assignmentLine = `Inspect assignment: pipe_get_assignment(pipeId="${pipeId}")`;
+ const fetchLine = `Read stage input: pipe_read_output(pipeId="${pipeId}")`;
+ const submitLine = `Submit: pipe_submit(pipeId="${pipeId}", content="")\nDo not use chat_send. Submit once, then wait.`;
+
+ const body = `${header}\n\n${guidance}\n\n${assignmentLine}\n${fetchLine}\n\n${submitLine}`;
+
+ return {
+ body,
+ pipe: { pipeId, mode, role, stage, targetAssignee },
+ };
+}
+
+/** Get delivery records where notification attempts are exhausted or state is expired.
+ * These represent assignments that failed to reach the assignee. */
+export function getExhaustedDeliveries(projectId: string | null): DeliveryRecord[] {
+ const results: DeliveryRecord[] = [];
+ const prefix = `${projectId ?? '__none__'}:`;
+ for (const [key, record] of deliveryRecords) {
+ if (!key.startsWith(prefix)) continue;
+ // Expired deliveries (retries exhausted or explicitly expired)
+ if (record.state === 'expired') {
+ results.push(record);
+ continue;
+ }
+ // Still active but retries exhausted — stuck in notified state
+ if (record.state === 'notified' && record.notifyAttempts >= record.maxNotifyAttempts) {
+ results.push(record);
+ }
+ }
+ return results;
+}
+
+// ── Test helper ─────────────────────────────────────────────────────────────
+
+/** Reset all in-memory delivery state. For testing only. */
+export function _resetForTest(): void {
+ deliveryRecords.clear();
+ for (const timer of renotifyTimers.values()) {
+ clearTimeout(timer);
+ }
+ renotifyTimers.clear();
+ _clock = systemClock;
+}
diff --git a/src/apps/chat/services/pipe-fetch-input.test.ts b/src/apps/chat/services/pipe-fetch-input.test.ts
new file mode 100644
index 0000000..5836050
--- /dev/null
+++ b/src/apps/chat/services/pipe-fetch-input.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ createPipe, computeStageInput, submitStage, markPipeStatus, _resetForTest,
+} from './pipe-store.js';
+
+describe('computeStageInput', () => {
+ beforeEach(() => _resetForTest());
+
+ // ── Linear pipes ──────────────────────────────────────────────────────
+
+ describe('linear pipes', () => {
+ it('stage 1 returns original prompt with hash', () => {
+ createPipe('p1', 'linear', ['alice', 'bob', 'carol'], 'analyze this', null);
+ const result = computeStageInput('p1', 1, 'alice', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('prompt');
+ expect(result.input.content).toBe('analyze this');
+ expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/);
+ expect(result.input.contentVersion).toBe(1);
+ expect(result.input.stage).toBe(1);
+ expect(result.input.totalStages).toBe(3);
+ expect(result.input.assignee).toBe('alice');
+ });
+
+ it('stage 2 returns upstream output after submission', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'analyze this', null);
+ submitStage('p1', 'alice', 'stage 1 output', null, false);
+ const result = computeStageInput('p1', 2, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('upstream-output');
+ expect(result.input.content).toBe('stage 1 output');
+ expect(result.input.sources).toEqual([{ from: 'alice', content: 'stage 1 output' }]);
+ expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/);
+ expect(result.input.contentVersion).toBe(1);
+ expect(result.input.prompt).toBe('analyze this');
+ });
+
+ it('stage 2 returns null content before upstream submits', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'analyze this', null);
+ const result = computeStageInput('p1', 2, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('upstream-output');
+ expect(result.input.content).toBeNull();
+ expect(result.input.contentHash).toBeNull();
+ expect(result.input.contentVersion).toBe(0);
+ });
+
+ it('infers stage from assignee position when stage is omitted', () => {
+ createPipe('p1', 'linear', ['alice', 'bob', 'carol'], 'test', null);
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.stage).toBe(2);
+ });
+
+ it('rejects invalid stage number', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'test', null);
+ const result = computeStageInput('p1', 5, 'alice', null);
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error).toContain('Invalid stage');
+ });
+ });
+
+ // ── Merge pipes ──────────────────────────────────────────────────────
+
+ describe('merge pipes', () => {
+ it('fan-out participant receives prompt', () => {
+ createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare approaches', null);
+ const result = computeStageInput('p1', undefined, 'alice', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-prompt');
+ expect(result.input.content).toBe('compare approaches');
+ expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it('synthesizer receives fan-out outputs after all submit', () => {
+ createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare', null);
+ submitStage('p1', 'alice', 'alice output', null, false);
+ submitStage('p1', 'bob', 'bob output', null, false);
+ const result = computeStageInput('p1', undefined, 'carol', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-outputs');
+ expect(result.input.sources).toHaveLength(2);
+ expect(result.input.sources![0].from).toBe('alice');
+ expect(result.input.sources![1].from).toBe('bob');
+ expect(result.input.contentHash).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it('synthesizer gets empty content before fan-outs submit', () => {
+ createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'compare', null);
+ const result = computeStageInput('p1', undefined, 'carol', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-outputs');
+ expect(result.input.content).toBeNull();
+ expect(result.input.contentHash).toBeNull();
+ });
+ });
+
+ // ── Merge-all pipes ──────────────────────────────────────────────────
+
+ describe('merge-all pipes', () => {
+ it('synthesizer in fan-out phase receives prompt', () => {
+ createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null);
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-prompt');
+ });
+
+ it('synthesizer stays in fan-out phase until their own fan-out is submitted', () => {
+ createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null);
+ submitStage('p1', 'alice', 'alice analysis', null, false);
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-prompt');
+ expect(result.input.content).toBe('explain this');
+ });
+
+ it('synthesizer in synthesis phase receives fan-out outputs (excluding self)', () => {
+ createPipe('p1', 'merge-all', ['alice', 'bob'], 'explain this', null);
+ submitStage('p1', 'alice', 'alice analysis', null, false);
+ submitStage('p1', 'bob', 'bob analysis', null, false);
+ // Now bob is in synthesis phase
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-outputs');
+ expect(result.input.sources).toHaveLength(1);
+ expect(result.input.sources![0].from).toBe('alice');
+ });
+ });
+
+ describe('merge-all style teaching pipes', () => {
+ it('explain keeps the synthesizer in prompt mode until their fan-out is submitted', () => {
+ createPipe('p1', 'explain', ['alice', 'bob'], 'teach me this', null);
+ submitStage('p1', 'alice', 'alice explanation', null, false);
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-prompt');
+ expect(result.input.content).toBe('teach me this');
+ });
+
+ it('summarize keeps the synthesizer in prompt mode until their fan-out is submitted', () => {
+ createPipe('p1', 'summarize', ['alice', 'bob'], 'summarize this', null);
+ submitStage('p1', 'alice', 'alice summary', null, false);
+ const result = computeStageInput('p1', undefined, 'bob', null);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.input.role).toBe('fan-out-prompt');
+ expect(result.input.content).toBe('summarize this');
+ });
+ });
+
+ // ── Error cases ──────────────────────────────────────────────────────
+
+ describe('error cases', () => {
+ it('returns error for non-existent pipe', () => {
+ const result = computeStageInput('nonexistent', 1, 'alice', null);
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe('PIPE_NOT_FOUND');
+ });
+
+ it('returns error for non-assignee', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'test', null);
+ const result = computeStageInput('p1', 1, 'eve', null);
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe('PIPE_NOT_ASSIGNED');
+ });
+
+ it('returns error for closed pipe', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'test', null);
+ markPipeStatus('p1', 'completed', null);
+ const result = computeStageInput('p1', 1, 'alice', null);
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.code).toBe('PIPE_CLOSED');
+ });
+
+ it('content hash is deterministic for same content', () => {
+ createPipe('p1', 'linear', ['alice', 'bob'], 'same prompt', null);
+ createPipe('p2', 'linear', ['alice', 'bob'], 'same prompt', null);
+ const r1 = computeStageInput('p1', 1, 'alice', null);
+ const r2 = computeStageInput('p2', 1, 'alice', null);
+ expect(r1.ok && r2.ok).toBe(true);
+ if (!r1.ok || !r2.ok) return;
+ expect(r1.input.contentHash).toBe(r2.input.contentHash);
+ });
+ });
+});
diff --git a/src/apps/chat/services/pipe-observability.test.ts b/src/apps/chat/services/pipe-observability.test.ts
new file mode 100644
index 0000000..b2d55cf
--- /dev/null
+++ b/src/apps/chat/services/pipe-observability.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as pipeStore from './pipe-store.js';
+import * as provenanceStore from './pipe-provenance.js';
+
+describe('Pipe Observability', () => {
+ const projectId = 'test-project';
+
+ beforeEach(() => {
+ pipeStore._resetForTest();
+ provenanceStore._resetForTest();
+ });
+
+ // ── Timing Summary ──────────────────────────────────────────────────
+
+ describe('getPipeTimingSummary', () => {
+ it('returns undefined for non-existent pipe', () => {
+ expect(pipeStore.getPipeTimingSummary('nope', projectId)).toBeUndefined();
+ });
+
+ it('returns timing for a running linear pipe', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing).toBeDefined();
+ expect(timing!.pipeId).toBe('p1');
+ expect(timing!.mode).toBe('linear');
+ expect(timing!.status).toBe('running');
+ expect(timing!.stages).toHaveLength(2);
+ expect(timing!.completedAt).toBeNull();
+ expect(timing!.totalDurationMs).toBeNull();
+ });
+
+ it('tracks submissions in timing stages', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'output1', projectId, true);
+ pipeStore.grantLease('p1', 'bob', projectId);
+ pipeStore.submitStage('p1', 'bob', 'output2', projectId, true);
+ pipeStore.markPipeStatus('p1', 'completed', projectId);
+
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing!.status).toBe('completed');
+ expect(timing!.completedAt).toBeDefined();
+ expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0);
+ expect(timing!.stages.every(s => s.submittedAt !== null)).toBe(true);
+ });
+
+ it('calculates critical path for merge pipe', () => {
+ pipeStore.createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'test', projectId);
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing!.stages.length).toBeGreaterThanOrEqual(3);
+ });
+ });
+
+ // ── Runtime Lease Statuses ──────────────────────────────────────────
+
+ describe('getRuntimeLeaseStatuses', () => {
+ it('returns empty array when no leases exist', () => {
+ expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toEqual([]);
+ });
+
+ it('returns active leases with timing fields', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, {
+ stageTimeoutMs: 30000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ const statuses = pipeStore.getRuntimeLeaseStatuses(projectId);
+ expect(statuses).toHaveLength(1);
+ expect(statuses[0].assignee).toBe('alice');
+ expect(statuses[0].elapsedMs).toBeGreaterThanOrEqual(0);
+ expect(statuses[0].remainingMs).toBeGreaterThan(0);
+ expect(statuses[0].isOverdue).toBe(false);
+ expect(statuses[0].deadline).toBeDefined();
+ });
+
+ it('handles leases without deadlines', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, {
+ stageTimeoutMs: 0,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ const statuses = pipeStore.getRuntimeLeaseStatuses(projectId);
+ expect(statuses[0].deadline).toBeNull();
+ expect(statuses[0].remainingMs).toBeNull();
+ expect(statuses[0].isOverdue).toBe(false);
+ });
+ });
+
+ // ── Dead Letter Entries ─────────────────────────────────────────────
+
+ describe('getDeadLetterEntries', () => {
+ it('returns empty array when no stuck assignments', () => {
+ expect(pipeStore.getDeadLetterEntries(projectId)).toEqual([]);
+ });
+
+ it('does not flag fresh running pipes', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, {
+ stageTimeoutMs: 300000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+ const entries = pipeStore.getDeadLetterEntries(projectId);
+ expect(entries).toHaveLength(0);
+ });
+
+ it('does not flag submitted slots', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'done', projectId, true);
+
+ const entries = pipeStore.getDeadLetterEntries(projectId);
+ expect(entries).toHaveLength(0);
+ });
+
+ it('returns correct structure for dead-letter entries', () => {
+ // Just verify the function returns a properly typed array
+ const entries = pipeStore.getDeadLetterEntries(projectId);
+ expect(Array.isArray(entries)).toBe(true);
+ });
+ });
+
+ // ── List All Pipes ──────────────────────────────────────────────────
+
+ describe('listAllPipes', () => {
+ it('returns empty array when no pipes', () => {
+ expect(pipeStore.listAllPipes(projectId)).toEqual([]);
+ });
+
+ it('includes running and terminal pipes with slot summaries', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.createPipe('p2', 'merge', ['alice', 'bob', 'carol'], 'test2', projectId);
+ pipeStore.markPipeStatus('p2', 'completed', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all).toHaveLength(2);
+
+ const p1 = all.find(p => p.pipeId === 'p1')!;
+ expect(p1.status).toBe('running');
+ expect(p1.slotSummary.total).toBe(2);
+ expect(p1.slotSummary.pending).toBe(2);
+
+ const p2 = all.find(p => p.pipeId === 'p2')!;
+ expect(p2.status).toBe('completed');
+ });
+ });
+
+ // ── Provenance Store ────────────────────────────────────────────────
+
+ describe('Provenance', () => {
+ it('records and retrieves provenance for a pipe', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user',
+ metadata: { mode: 'linear' },
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'stage-granted', actor: 'system', actorKind: 'system',
+ stage: 1, metadata: { assignee: 'alice' },
+ });
+
+ const records = provenanceStore.getProvenanceForPipe('p1', projectId);
+ expect(records).toHaveLength(2);
+ expect(records[0].event).toBe('created');
+ expect(records[1].event).toBe('stage-granted');
+ expect(records[1].stage).toBe(1);
+ });
+
+ it('queries by actor', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'stage-submitted', actor: 'alice', actorKind: 'llm',
+ });
+
+ const userRecords = provenanceStore.queryProvenance(projectId, { actor: 'user' });
+ expect(userRecords).toHaveLength(1);
+ expect(userRecords[0].actor).toBe('user');
+ });
+
+ it('queries by event type', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p2', event: 'created', actor: 'user', actorKind: 'user',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'completed', actor: 'system', actorKind: 'system',
+ });
+
+ const created = provenanceStore.queryProvenance(projectId, { event: 'created' });
+ expect(created).toHaveLength(2);
+ });
+
+ it('retrieves provenance for participant across pipes', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'stage-submitted', actor: 'alice', actorKind: 'llm',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p2', event: 'stage-submitted', actor: 'alice', actorKind: 'llm',
+ });
+
+ const aliceRecords = provenanceStore.getProvenanceForParticipant('alice', projectId);
+ expect(aliceRecords).toHaveLength(2);
+ });
+
+ it('cleans up provenance for terminal pipes', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p2', event: 'created', actor: 'user', actorKind: 'user',
+ });
+
+ provenanceStore.cleanupProvenance(['p1'], projectId);
+ expect(provenanceStore.getProvenanceForPipe('p1', projectId)).toHaveLength(0);
+ expect(provenanceStore.getProvenanceForPipe('p2', projectId)).toHaveLength(1);
+ });
+ });
+});
diff --git a/src/apps/chat/services/pipe-parser.test.ts b/src/apps/chat/services/pipe-parser.test.ts
new file mode 100644
index 0000000..60cf567
--- /dev/null
+++ b/src/apps/chat/services/pipe-parser.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from 'vitest';
+import { isPipeCommand, parsePipeCommand, validatePipeAssigneeCount, isBrainstormCommand, parseBrainstormCommand } from './pipe-parser.js';
+
+describe('pipe-parser', () => {
+ it('recognizes /explain as a pipe command', () => {
+ expect(isPipeCommand('/explain teach me this')).toBe(true);
+ });
+
+ it('recognizes /summarize as a pipe command', () => {
+ expect(isPipeCommand('/summarize boil this down')).toBe(true);
+ });
+
+ it('parses /explain with explicit assignees without a colon', () => {
+ expect(parsePipeCommand('/explain @alice @bob explain the bug')).toEqual({
+ mode: 'explain',
+ assignees: ['alice', 'bob'],
+ prompt: 'explain the bug',
+ });
+ });
+
+ it('still accepts a legacy colon separator', () => {
+ expect(parsePipeCommand('/explain @alice @bob: explain the bug')).toEqual({
+ mode: 'explain',
+ assignees: ['alice', 'bob'],
+ prompt: 'explain the bug',
+ });
+ });
+
+ it('parses /summarize with explicit assignees', () => {
+ expect(parsePipeCommand('/summarize @alice @bob summarize this topic')).toEqual({
+ mode: 'summarize',
+ assignees: ['alice', 'bob'],
+ prompt: 'summarize this topic',
+ });
+ });
+
+ it('allows prompt-only commands so defaults can be resolved later', () => {
+ expect(parsePipeCommand('/merge-pipe compare these options')).toEqual({
+ mode: 'merge',
+ assignees: [],
+ prompt: 'compare these options',
+ });
+ });
+
+ it('stops assignee parsing when a leading @token is not a known participant', () => {
+ expect(
+ parsePipeCommand(
+ '/merge-all-pipe @alice @bob @user mentioned this bug',
+ (name) => name === 'alice' || name === 'bob',
+ ),
+ ).toEqual({
+ mode: 'merge-all',
+ assignees: ['alice', 'bob'],
+ prompt: '@user mentioned this bug',
+ });
+ });
+
+ it('rejects duplicate assignees', () => {
+ expect(parsePipeCommand('/explain @alice @alice duplicate')).toEqual({
+ error: 'Duplicate assignees not allowed.',
+ });
+ });
+
+ it('validates minimum assignee counts after resolution', () => {
+ expect(validatePipeAssigneeCount('linear', 1)).toBe('/linear-pipe requires at least 2 assignees.');
+ expect(validatePipeAssigneeCount('merge', 2)).toBe('/merge-pipe requires at least 3 assignees (last one synthesizes).');
+ expect(validatePipeAssigneeCount('explain', 1)).toBe('/explain requires at least 2 assignees.');
+ expect(validatePipeAssigneeCount('explain', 2)).toBeNull();
+ expect(validatePipeAssigneeCount('summarize', 1)).toBe('/summarize requires at least 2 assignees.');
+ expect(validatePipeAssigneeCount('summarize', 2)).toBeNull();
+ });
+});
+
+describe('brainstorm-parser', () => {
+ it('recognizes /brainstorm as a brainstorm command', () => {
+ expect(isBrainstormCommand('/brainstorm design a cache')).toBe(true);
+ });
+
+ it('does not recognize /brainstorming or other variants', () => {
+ expect(isBrainstormCommand('/brainstorming ideas')).toBe(false);
+ expect(isBrainstormCommand('/linear-pipe @a @b do work')).toBe(false);
+ expect(isBrainstormCommand('brainstorm something')).toBe(false);
+ });
+
+ it('parses /brainstorm with explicit assignees and colon', () => {
+ expect(parseBrainstormCommand('/brainstorm @alice @bob : design a cache')).toEqual({
+ assignees: ['alice', 'bob'],
+ prompt: 'design a cache',
+ });
+ });
+
+ it('parses /brainstorm with explicit assignees without colon', () => {
+ expect(parseBrainstormCommand('/brainstorm @alice @bob design a cache')).toEqual({
+ assignees: ['alice', 'bob'],
+ prompt: 'design a cache',
+ });
+ });
+
+ it('parses /brainstorm with no assignees (prompt only)', () => {
+ expect(parseBrainstormCommand('/brainstorm design a cache')).toEqual({
+ assignees: [],
+ prompt: 'design a cache',
+ });
+ });
+
+ it('parses /brainstorm with colon and no assignees', () => {
+ expect(parseBrainstormCommand('/brainstorm : design a cache')).toEqual({
+ assignees: [],
+ prompt: 'design a cache',
+ });
+ });
+
+ it('returns error for empty prompt', () => {
+ expect(parseBrainstormCommand('/brainstorm @alice @bob')).toEqual({
+ error: 'Brainstorm prompt cannot be empty.',
+ });
+ });
+
+ it('returns error for duplicate assignees', () => {
+ expect(parseBrainstormCommand('/brainstorm @alice @alice design a cache')).toEqual({
+ error: 'Duplicate assignees not allowed.',
+ });
+ });
+
+ it('returns error when first leading @name is unknown (no valid assignees)', () => {
+ const known = new Set(['alice']);
+ const result = parseBrainstormCommand(
+ '/brainstorm @ghost design a cache',
+ (name) => known.has(name),
+ );
+ expect(result).toEqual({
+ error: 'Unknown assignee @ghost. All assignees must be connected LLM participants.',
+ });
+ });
+
+ it('treats unknown @name as prompt text when valid assignees precede it', () => {
+ const known = new Set(['alice', 'bob']);
+ const result = parseBrainstormCommand(
+ '/brainstorm @alice @bob @user wants a cache layer',
+ (name) => known.has(name),
+ );
+ expect(result).toEqual({
+ assignees: ['alice', 'bob'],
+ prompt: '@user wants a cache layer',
+ });
+ });
+
+ it('accepts all assignees when validator confirms them', () => {
+ const known = new Set(['alice', 'bob']);
+ const result = parseBrainstormCommand(
+ '/brainstorm @alice @bob : design a cache',
+ (name) => known.has(name),
+ );
+ expect(result).toEqual({
+ assignees: ['alice', 'bob'],
+ prompt: 'design a cache',
+ });
+ });
+});
diff --git a/src/apps/chat/services/pipe-parser.ts b/src/apps/chat/services/pipe-parser.ts
new file mode 100644
index 0000000..83e54f6
--- /dev/null
+++ b/src/apps/chat/services/pipe-parser.ts
@@ -0,0 +1,215 @@
+import type { PipeMode, PipeTimeoutPolicy } from '../types.js';
+
+export interface ParsedPipeCommand {
+ mode: PipeMode;
+ assignees: string[];
+ prompt: string;
+ stageTimeoutMs?: number;
+ timeoutPolicy?: PipeTimeoutPolicy;
+}
+
+export interface PipeParseError {
+ error: string;
+}
+
+export type PipeParseResult = ParsedPipeCommand | PipeParseError;
+
+const PIPE_COMMANDS = ['linear-pipe', 'merge-pipe', 'merge-all-pipe', 'explain', 'explain-pipe', 'summarize', 'summarize-pipe'] as const;
+const PIPE_CMD_RE = /^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+/;
+
+function commandLabel(mode: PipeMode): string {
+ switch (mode) {
+ case 'linear':
+ return '/linear-pipe';
+ case 'merge':
+ return '/merge-pipe';
+ case 'merge-all':
+ return '/merge-all-pipe';
+ case 'explain':
+ return '/explain';
+ case 'summarize':
+ return '/summarize';
+ default:
+ return '/merge-all-pipe';
+ }
+}
+
+export function validatePipeAssigneeCount(mode: PipeMode, assigneeCount: number): string | null {
+ if (mode === 'linear' && assigneeCount < 2) {
+ return `${commandLabel(mode)} requires at least 2 assignees.`;
+ }
+ if (mode === 'merge' && assigneeCount < 3) {
+ return `${commandLabel(mode)} requires at least 3 assignees (last one synthesizes).`;
+ }
+ if ((mode === 'merge-all' || mode === 'explain' || mode === 'summarize') && assigneeCount < 2) {
+ return `${commandLabel(mode)} requires at least 2 assignees.`;
+ }
+ return null;
+}
+
+export function isPipeCommand(body: string): boolean {
+ return PIPE_CMD_RE.test(body.trim());
+}
+
+/** Parse a human-friendly duration string (e.g. "5m", "30s", "1h") to milliseconds. */
+export function parseDuration(s: string): number | null {
+ const match = s.match(/^(\d+)(s|m|h)$/);
+ if (!match) return null;
+ const value = parseInt(match[1], 10);
+ if (value <= 0) return null;
+ const unit = match[2];
+ if (unit === 's') return value * 1000;
+ if (unit === 'm') return value * 60 * 1000;
+ if (unit === 'h') return value * 60 * 60 * 1000;
+ return null;
+}
+
+const VALID_TIMEOUT_POLICIES = ['fail', 'reassign', 'escalate'] as const;
+
+export function parsePipeCommand(
+ body: string,
+ isKnownAssignee?: (name: string) => boolean,
+): PipeParseResult {
+ const trimmed = body.trim();
+ const cmdMatch = trimmed.match(/^\/(linear-pipe|merge-pipe|merge-all-pipe|explain(?:-pipe)?|summarize(?:-pipe)?)\s+([\s\S]+)$/);
+ if (!cmdMatch) {
+ return { error: `Invalid pipe command. Use ${PIPE_COMMANDS.map(cmd => `/${cmd}`).join(', ')}.` };
+ }
+
+ const cmd = cmdMatch[1];
+ let mode: PipeMode;
+ if (cmd === 'linear-pipe') mode = 'linear';
+ else if (cmd === 'merge-pipe') mode = 'merge';
+ else if (cmd === 'merge-all-pipe') mode = 'merge-all';
+ else if (cmd === 'explain' || cmd === 'explain-pipe') mode = 'explain';
+ else mode = 'summarize';
+
+ let remaining = cmdMatch[2].trim();
+
+ // Parse optional flags (--timeout , --on-timeout ) before @assignees
+ let stageTimeoutMs: number | undefined;
+ let timeoutPolicy: PipeTimeoutPolicy | undefined;
+
+ while (true) {
+ const flagMatch = remaining.match(/^--([\w-]+)\s+(\S+)\s*/);
+ if (!flagMatch) break;
+
+ const flagName = flagMatch[1];
+ const flagValue = flagMatch[2];
+
+ if (flagName === 'timeout') {
+ const ms = parseDuration(flagValue);
+ if (ms === null) return { error: `Invalid timeout duration: ${flagValue}. Use e.g. 5m, 30s, 1h.` };
+ stageTimeoutMs = ms;
+ } else if (flagName === 'on-timeout') {
+ if (!(VALID_TIMEOUT_POLICIES as readonly string[]).includes(flagValue)) {
+ return { error: `Invalid timeout policy: ${flagValue}. Use fail, reassign, or escalate.` };
+ }
+ timeoutPolicy = flagValue as PipeTimeoutPolicy;
+ } else {
+ break; // Unknown flag — stop flag parsing, rest is @mentions/prompt
+ }
+
+ remaining = remaining.slice(flagMatch[0].length);
+ }
+
+ const assignees: string[] = [];
+
+ while (true) {
+ const match = remaining.match(/^@([\w-]+)(?=\s|:|$)/);
+ if (!match) break;
+
+ const name = match[1];
+ if (isKnownAssignee && !isKnownAssignee(name)) break;
+
+ assignees.push(name);
+ remaining = remaining.slice(match[0].length).trimStart();
+ }
+
+ if (remaining.startsWith(':')) {
+ remaining = remaining.slice(1).trimStart();
+ }
+
+ const prompt = remaining.trim();
+ if (!prompt) {
+ return { error: 'Prompt cannot be empty.' };
+ }
+
+ const unique = new Set(assignees);
+ if (unique.size !== assignees.length) {
+ return { error: 'Duplicate assignees not allowed.' };
+ }
+
+ return {
+ mode,
+ assignees,
+ prompt,
+ ...(stageTimeoutMs !== undefined ? { stageTimeoutMs } : {}),
+ ...(timeoutPolicy !== undefined ? { timeoutPolicy } : {}),
+ };
+}
+
+export function isPipeParseError(result: PipeParseResult): result is PipeParseError {
+ return 'error' in result;
+}
+
+// ── Brainstorm command parsing ────────────────────────────────────────────────
+
+const BRAINSTORM_CMD_RE = /^\/brainstorm\s+/;
+
+export interface ParsedBrainstormCommand {
+ assignees: string[];
+ prompt: string;
+}
+
+export function isBrainstormCommand(body: string): boolean {
+ return BRAINSTORM_CMD_RE.test(body.trim());
+}
+
+export function parseBrainstormCommand(
+ body: string,
+ isKnownAssignee?: (name: string) => boolean,
+): ParsedBrainstormCommand | PipeParseError {
+ const trimmed = body.trim();
+ const cmdMatch = trimmed.match(/^\/brainstorm\s+([\s\S]+)$/);
+ if (!cmdMatch) {
+ return { error: 'Invalid brainstorm command. Usage: /brainstorm @agent1 @agent2 : topic' };
+ }
+
+ let remaining = cmdMatch[1].trim();
+ const assignees: string[] = [];
+ let stoppedAtUnknown: string | null = null;
+
+ while (true) {
+ const match = remaining.match(/^@([\w-]+)(?=\s|:|$)/);
+ if (!match) break;
+ const name = match[1];
+ if (isKnownAssignee && !isKnownAssignee(name)) {
+ stoppedAtUnknown = name;
+ break;
+ }
+ assignees.push(name);
+ remaining = remaining.slice(match[0].length).trimStart();
+ }
+
+ // If no valid assignees were parsed but user wrote @names, the first was unknown
+ if (stoppedAtUnknown && assignees.length === 0) {
+ return { error: `Unknown assignee @${stoppedAtUnknown}. All assignees must be connected LLM participants.` };
+ }
+
+ if (remaining.startsWith(':')) {
+ remaining = remaining.slice(1).trimStart();
+ }
+
+ const prompt = remaining.trim();
+ if (!prompt) {
+ return { error: 'Brainstorm prompt cannot be empty.' };
+ }
+
+ const unique = new Set(assignees);
+ if (unique.size !== assignees.length) {
+ return { error: 'Duplicate assignees not allowed.' };
+ }
+
+ return { assignees, prompt };
+}
diff --git a/src/apps/chat/services/pipe-provenance.ts b/src/apps/chat/services/pipe-provenance.ts
new file mode 100644
index 0000000..fbe4c06
--- /dev/null
+++ b/src/apps/chat/services/pipe-provenance.ts
@@ -0,0 +1,102 @@
+import type { ProvenanceRecord } from '../types.js';
+import { systemClock, type Clock } from './clock.js';
+
+// ── Clock ────────────────────────────────────────────────────────────────────
+
+let clock: Clock = systemClock;
+
+/** Override the clock used by provenance store (for deterministic testing). */
+export function _setClockForTest(c: Clock): void { clock = c; }
+
+// ── Storage ──────────────────────────────────────────────────────────────────
+
+// projectId -> (pipeId -> ProvenanceRecord[])
+const provenanceStores = new Map>();
+
+function getProjectStore(projectId: string | null): Map {
+ let store = provenanceStores.get(projectId);
+ if (!store) {
+ store = new Map();
+ provenanceStores.set(projectId, store);
+ }
+ return store;
+}
+
+// ── Recording ────────────────────────────────────────────────────────────────
+
+/** Record a provenance event for a pipe. */
+export function recordProvenance(
+ projectId: string | null,
+ record: Omit,
+): ProvenanceRecord {
+ const store = getProjectStore(projectId);
+ const full: ProvenanceRecord = { ...record, ts: clock.isoNow() };
+ let records = store.get(record.pipeId);
+ if (!records) { records = []; store.set(record.pipeId, records); }
+ records.push(full);
+ return full;
+}
+
+// ── Queries ──────────────────────────────────────────────────────────────────
+
+/** Get all provenance records for a pipe, ordered chronologically. */
+export function getProvenanceForPipe(pipeId: string, projectId: string | null): ProvenanceRecord[] {
+ return getProjectStore(projectId).get(pipeId) ?? [];
+}
+
+/** Get all provenance records for a participant across all pipes. */
+export function getProvenanceForParticipant(
+ actor: string,
+ projectId: string | null,
+): ProvenanceRecord[] {
+ const store = getProjectStore(projectId);
+ const result: ProvenanceRecord[] = [];
+ for (const records of store.values()) {
+ for (const r of records) {
+ if (r.actor === actor) result.push(r);
+ }
+ }
+ return result.sort((a, b) => a.ts.localeCompare(b.ts));
+}
+
+/** Query provenance records with flexible filters. */
+export function queryProvenance(
+ projectId: string | null,
+ filters?: {
+ pipeId?: string;
+ actor?: string;
+ event?: ProvenanceRecord['event'];
+ since?: string;
+ },
+): ProvenanceRecord[] {
+ const store = getProjectStore(projectId);
+ const result: ProvenanceRecord[] = [];
+
+ for (const [pipeId, records] of store) {
+ if (filters?.pipeId && pipeId !== filters.pipeId) continue;
+ for (const r of records) {
+ if (filters?.actor && r.actor !== filters.actor) continue;
+ if (filters?.event && r.event !== filters.event) continue;
+ if (filters?.since && r.ts < filters.since) continue;
+ result.push(r);
+ }
+ }
+
+ return result.sort((a, b) => a.ts.localeCompare(b.ts));
+}
+
+// ── Cleanup ──────────────────────────────────────────────────────────────────
+
+/** Remove provenance records for terminal pipes. */
+export function cleanupProvenance(pipeIds: string[], projectId: string | null): void {
+ const store = getProjectStore(projectId);
+ for (const id of pipeIds) {
+ store.delete(id);
+ }
+}
+
+/** Reset all state (for testing). */
+export function _resetForTest(): void {
+ provenanceStores.clear();
+ clock = systemClock;
+}
diff --git a/src/apps/chat/services/pipe-reconnect-recovery.test.ts b/src/apps/chat/services/pipe-reconnect-recovery.test.ts
new file mode 100644
index 0000000..7fa02e8
--- /dev/null
+++ b/src/apps/chat/services/pipe-reconnect-recovery.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as pipeStore from './pipe-store.js';
+
+describe('Reconnect recovery — assignment queries', () => {
+ beforeEach(() => {
+ pipeStore._resetForTest();
+ });
+
+ it('getAssignmentsForParticipant returns pending slots for running pipes', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test prompt', null);
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(1);
+ expect(assignments[0].pipeId).toBe('pipe-1');
+ expect(assignments[0].slotStatus).toBe('pending');
+ });
+
+ it('getAssignmentsForParticipant excludes submitted slots', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null);
+ pipeStore.grantLease('pipe-1', 'alice', null);
+ pipeStore.submitStage('pipe-1', 'alice', 'output', null);
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(0);
+ });
+
+ it('getAssignmentsForParticipant shows leased slot with active lease status', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null);
+ pipeStore.grantLease('pipe-1', 'alice', null);
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(1);
+ expect(assignments[0].slotStatus).toBe('leased');
+ expect(assignments[0].leaseStatus).toBe('active');
+ });
+
+ it('getAssignmentsForParticipant returns empty for non-running pipes', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null);
+ pipeStore.markPipeStatus('pipe-1', 'failed', null);
+ // markPipeStatus removes from activePipeIndex
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(0);
+ });
+
+ it('getAssignmentsForParticipant returns assignments across multiple pipes', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt 1', null);
+ pipeStore.createPipe('pipe-2', 'linear', ['alice', 'carol'], 'prompt 2', null);
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(2);
+ expect(assignments.map(a => a.pipeId)).toContain('pipe-1');
+ expect(assignments.map(a => a.pipeId)).toContain('pipe-2');
+ });
+
+ it('expired lease is detected in assignment listing', () => {
+ vi.useFakeTimers();
+ try {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, {
+ stageTimeoutMs: 5000,
+ });
+ pipeStore.grantLease('pipe-1', 'alice', null);
+ vi.advanceTimersByTime(6000);
+ const assignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(assignments.length).toBe(1);
+ expect(assignments[0].leaseStatus).toBe('expired');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('isLeaseExpired returns false for lease without deadline', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, {
+ stageTimeoutMs: 0,
+ });
+ const result = pipeStore.grantLease('pipe-1', 'alice', null);
+ expect(result.ok).toBe(true);
+ expect(result.lease!.deadline).toBeNull();
+ expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false);
+ });
+
+ it('isLeaseExpired returns true for expired lease', () => {
+ vi.useFakeTimers();
+ try {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', null, {
+ stageTimeoutMs: 5000,
+ });
+ const result = pipeStore.grantLease('pipe-1', 'alice', null);
+ expect(result.ok).toBe(true);
+ vi.advanceTimersByTime(6000);
+ expect(pipeStore.isLeaseExpired(result.lease!)).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('getAssignmentsForParticipant scopes by projectId', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-a');
+ pipeStore.createPipe('pipe-2', 'linear', ['alice', 'carol'], 'test', 'proj-b');
+ const assignmentsA = pipeStore.getAssignmentsForParticipant('alice', 'proj-a');
+ const assignmentsB = pipeStore.getAssignmentsForParticipant('alice', 'proj-b');
+ expect(assignmentsA.length).toBe(1);
+ expect(assignmentsA[0].pipeId).toBe('pipe-1');
+ expect(assignmentsB.length).toBe(1);
+ expect(assignmentsB[0].pipeId).toBe('pipe-2');
+ });
+
+ it('merge-all pipe shows both fan-out and final slots for last assignee', () => {
+ pipeStore.createPipe('pipe-1', 'merge-all', ['alice', 'bob'], 'test', null);
+ // bob (last) should have both fan-out and final slots
+ const assignments = pipeStore.getAssignmentsForParticipant('bob', null);
+ expect(assignments.length).toBe(2);
+ expect(assignments.map(a => a.role)).toContain('fan-out');
+ expect(assignments.map(a => a.role)).toContain('final');
+ });
+
+ it('rehydrated pipe preserves assignments for reconnecting participants', () => {
+ // Simulate: pipe created, stage 1 submitted, then server restart
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'pipe-r', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'pipe-r', from: 'alice', content: 'alice output' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, null);
+ expect(running).toContain('pipe-r');
+
+ // bob should have a pending assignment (stage 2)
+ const bobAssignments = pipeStore.getAssignmentsForParticipant('bob', null);
+ expect(bobAssignments.length).toBe(1);
+ expect(bobAssignments[0].pipeId).toBe('pipe-r');
+ expect(bobAssignments[0].slotStatus).toBe('pending');
+
+ // alice should have no pending assignments (already submitted)
+ const aliceAssignments = pipeStore.getAssignmentsForParticipant('alice', null);
+ expect(aliceAssignments.length).toBe(0);
+
+ // carol should have a pending assignment (stage 3, not yet reached)
+ const carolAssignments = pipeStore.getAssignmentsForParticipant('carol', null);
+ expect(carolAssignments.length).toBe(1);
+ expect(carolAssignments[0].slotStatus).toBe('pending');
+ });
+});
diff --git a/src/apps/chat/services/pipe-reducer.test.ts b/src/apps/chat/services/pipe-reducer.test.ts
new file mode 100644
index 0000000..3422a36
--- /dev/null
+++ b/src/apps/chat/services/pipe-reducer.test.ts
@@ -0,0 +1,383 @@
+import { describe, expect, it } from 'vitest';
+import type { ChatMessage } from '../types.js';
+import {
+ derivePipeState,
+ computeNextActions,
+ matchResponse,
+} from './pipe-reducer.js';
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+let seq = 0;
+function msg(overrides: Partial & { from: string; body: string }): ChatMessage {
+ return {
+ id: `msg-${++seq}`,
+ ts: new Date(Date.now() + seq * 1000).toISOString(),
+ to: null,
+ type: 'message',
+ ...overrides,
+ };
+}
+
+function sysMsg(body: string, pipe: ChatMessage['pipe']): ChatMessage {
+ return msg({ from: 'system', body, type: 'system', pipe });
+}
+
+// ── derivePipeState ──────────────────────────────────────────────────────────
+
+describe('derivePipeState', () => {
+ it('returns null for unknown pipeId', () => {
+ expect(derivePipeState([], 'nope')).toBeNull();
+ });
+
+ it('derives running state from start message', () => {
+ const messages = [
+ sysMsg('Pipe started', {
+ pipeId: 'abc', mode: 'linear', role: 'start',
+ assignees: ['a', 'b'], prompt: 'solve X',
+ }),
+ ];
+ const state = derivePipeState(messages, 'abc');
+ expect(state).not.toBeNull();
+ expect(state!.status).toBe('running');
+ expect(state!.prompt).toBe('solve X');
+ expect(state!.assignees).toEqual(['a', 'b']);
+ });
+
+ it('reads prompt from pipe metadata, not message body', () => {
+ const messages = [
+ sysMsg('#pipe-abc Pipe started (linear): @a → @b', {
+ pipeId: 'abc', mode: 'linear', role: 'start',
+ assignees: ['a', 'b'], prompt: 'the real prompt',
+ }),
+ ];
+ const state = derivePipeState(messages, 'abc');
+ expect(state!.prompt).toBe('the real prompt');
+ });
+
+ it('marks completed on final role', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'final', stage: 1 } }),
+ ];
+ const state = derivePipeState(messages, 'abc');
+ expect(state!.status).toBe('completed');
+ expect(state!.hasFinal).toBe(true);
+ });
+
+ it('marks failed on failed role', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('unavail', { pipeId: 'abc', mode: 'merge', role: 'assignee-unavailable', targetAssignee: 'a', reason: 'left' }),
+ sysMsg('failed', { pipeId: 'abc', mode: 'merge', role: 'failed', reason: 'left' }),
+ ];
+ const state = derivePipeState(messages, 'abc');
+ expect(state!.status).toBe('failed');
+ });
+
+ it('marks cancelled on cancelled role', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('cancelled', { pipeId: 'abc', mode: 'linear', role: 'cancelled', reason: 'cancelled-by-user' }),
+ ];
+ const state = derivePipeState(messages, 'abc');
+ expect(state!.status).toBe('cancelled');
+ });
+});
+
+// ── computeNextActions — idempotency ─────────────────────────────────────────
+
+describe('computeNextActions — linear', () => {
+ it('emits initial handoff to first assignee', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('handoff');
+ expect(actions[0].targetAssignee).toBe('a');
+ expect(actions[0].stage).toBe(1);
+ });
+
+ it('does not duplicate handoff if already emitted', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(0); // waiting for a's response
+ });
+
+ it('emits handoff to next stage after output', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ msg({ from: 'a', body: 'output A', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('handoff');
+ expect(actions[0].targetAssignee).toBe('b');
+ expect(actions[0].stage).toBe(2);
+ });
+
+ it('includes compact prompt with pipe_submit in handoff body', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ const body = actions[0].body;
+ expect(body).toContain('#pipe-abc [linear | stage 1/2 | @a]');
+ expect(body).toContain('pipe_submit(pipeId="abc"');
+ expect(body).toContain('Do not use chat_send');
+ expect(body).toContain('next stage');
+ expect(body).toContain('Prompt: X');
+ });
+
+ it('emits nothing after terminal state', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'final', stage: 1 } }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(computeNextActions(state)).toHaveLength(0);
+ });
+});
+
+describe('computeNextActions — merge', () => {
+ it('emits fan-out requests to all fan-out assignees', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(2);
+ expect(actions.map(a => a.targetAssignee).sort()).toEqual(['a', 'b']);
+ });
+
+ it('emits synth-request when all fan-out replies are in', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('fan-out-req a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fan-out-req b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A output', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ msg({ from: 'b', body: 'B output', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('synth-request');
+ expect(actions[0].targetAssignee).toBe('s');
+ });
+
+ it('merge-all: emits fan-out requests to ALL assignees (including synthesizer)', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge-all', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(state);
+ expect(actions).toHaveLength(2);
+ expect(actions.map(a => a.targetAssignee).sort()).toEqual(['a', 'b']);
+
+ // Synthesizer's fan-out should warn about dual-role
+ const bFanOut = actions.find(a => a.targetAssignee === 'b')!;
+ expect(bFanOut.body).toContain('You have 2 stages');
+ expect(bFanOut.body).toContain('Synthesis comes next');
+ // Non-synthesizer should not have the warning
+ const aFanOut = actions.find(a => a.targetAssignee === 'a')!;
+ expect(aFanOut.body).not.toContain('You have 2 stages');
+ });
+
+ it('merge-all: emits synth-request only after ALL fan-outs (including synthesizer) are in', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge-all', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('fo a', { pipeId: 'abc', mode: 'merge-all', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fo b', { pipeId: 'abc', mode: 'merge-all', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge-all', role: 'fan-out' } }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+
+ // b (synthesizer) hasn't sent its fan-out yet
+ expect(computeNextActions(state)).toHaveLength(0);
+
+ // b sends fan-out
+ messages.push(msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge-all', role: 'fan-out' } }));
+ const nextState = derivePipeState(messages, 'abc')!;
+ const actions = computeNextActions(nextState);
+ expect(actions).toHaveLength(1);
+ expect(actions[0].type).toBe('synth-request');
+ expect(actions[0].targetAssignee).toBe('b');
+ expect(actions[0].body).toContain('pipe_read_output(pipeId="abc")');
+ expect(actions[0].body).toContain('pipe_submit(pipeId="abc"');
+ expect(actions[0].body).toContain('Do not use chat_send');
+ });
+
+ it('explain: emits teaching fan-out requests and a teaching synth request', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'explain', role: 'start', assignees: ['a', 'b'], prompt: 'Teach me X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const fanOutActions = computeNextActions(state);
+ expect(fanOutActions).toHaveLength(2);
+ expect(fanOutActions[0].body).toContain('Explain independently');
+ expect(fanOutActions[0].body).toContain('Simplest explanation');
+ expect(fanOutActions[0].body).toContain('pipe_submit(pipeId="abc"');
+ expect(fanOutActions[0].body).toContain('Do not use chat_send');
+
+ messages.push(
+ sysMsg('fo a', { pipeId: 'abc', mode: 'explain', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fo b', { pipeId: 'abc', mode: 'explain', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'explain', role: 'fan-out' } }),
+ msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'explain', role: 'fan-out' } }),
+ );
+
+ const nextState = derivePipeState(messages, 'abc')!;
+ const synthActions = computeNextActions(nextState);
+ expect(synthActions).toHaveLength(1);
+ expect(synthActions[0].targetAssignee).toBe('b');
+ expect(synthActions[0].body).toContain('Common misunderstandings');
+ expect(synthActions[0].body).toContain('pipe_read_output(pipeId="abc")');
+ expect(synthActions[0].body).toContain('pipe_submit(pipeId="abc"');
+ });
+
+ it('summarize: emits concise fan-out requests and a compact synth request', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'summarize', role: 'start', assignees: ['a', 'b'], prompt: 'Summarize topic X' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const fanOutActions = computeNextActions(state);
+ expect(fanOutActions).toHaveLength(2);
+ expect(fanOutActions[0].body).toContain('Summarize independently');
+ expect(fanOutActions[0].body).toContain('TL;DR');
+ expect(fanOutActions[0].body).toContain('pipe_submit(pipeId="abc"');
+ expect(fanOutActions[0].body).toContain('Do not use chat_send');
+
+ messages.push(
+ sysMsg('fo a', { pipeId: 'abc', mode: 'summarize', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fo b', { pipeId: 'abc', mode: 'summarize', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'summarize', role: 'fan-out' } }),
+ msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'summarize', role: 'fan-out' } }),
+ );
+
+ const nextState = derivePipeState(messages, 'abc')!;
+ const synthActions = computeNextActions(nextState);
+ expect(synthActions).toHaveLength(1);
+ expect(synthActions[0].targetAssignee).toBe('b');
+ expect(synthActions[0].body).toContain('1. TL;DR');
+ expect(synthActions[0].body).toContain('Caveat (only if important)');
+ expect(synthActions[0].body).toContain('pipe_read_output(pipeId="abc")');
+ expect(synthActions[0].body).toContain('pipe_submit(pipeId="abc"');
+ });
+
+ it('does not duplicate synth-request', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fo b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ sysMsg('synth', { pipeId: 'abc', mode: 'merge', role: 'synth-request', targetAssignee: 's' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(computeNextActions(state)).toHaveLength(0);
+ });
+
+ it('emits nothing after failed state', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('unavail', { pipeId: 'abc', mode: 'merge', role: 'assignee-unavailable', targetAssignee: 'a', reason: 'left' }),
+ sysMsg('failed', { pipeId: 'abc', mode: 'merge', role: 'failed', reason: 'left' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(computeNextActions(state)).toHaveLength(0);
+ });
+});
+
+// ── matchResponse — reply disambiguation ─────────────────────────────────────
+
+describe('matchResponse', () => {
+ it('matches linear stage-output for prompted assignee', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const meta = matchResponse(state, 'a');
+ expect(meta).not.toBeNull();
+ expect(meta!.role).toBe('stage-output');
+ expect(meta!.stage).toBe(1);
+ });
+
+ it('does not match unprompted assignee', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ // no handoff emitted yet
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(matchResponse(state, 'a')).toBeNull();
+ });
+
+ it('does not match already-responded assignee', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ msg({ from: 'a', body: 'done', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(matchResponse(state, 'a')).toBeNull();
+ });
+
+ it('matches final for last linear assignee', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'linear', role: 'stage-output', stage: 1 } }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 2, targetAssignee: 'b' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const meta = matchResponse(state, 'b');
+ expect(meta!.role).toBe('final');
+ });
+
+ it('matches merge fan-out response', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('fo', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const meta = matchResponse(state, 'a');
+ expect(meta!.role).toBe('fan-out');
+ });
+
+ it('matches merge synthesizer final response', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'merge', role: 'start', assignees: ['a', 'b', 's'], prompt: 'X' }),
+ sysMsg('fo a', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'a' }),
+ sysMsg('fo b', { pipeId: 'abc', mode: 'merge', role: 'fan-out-request', targetAssignee: 'b' }),
+ msg({ from: 'a', body: 'A', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ msg({ from: 'b', body: 'B', pipe: { pipeId: 'abc', mode: 'merge', role: 'fan-out' } }),
+ sysMsg('synth', { pipeId: 'abc', mode: 'merge', role: 'synth-request', targetAssignee: 's' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ const meta = matchResponse(state, 's');
+ expect(meta!.role).toBe('final');
+ });
+
+ it('does not match non-participant', () => {
+ const messages = [
+ sysMsg('start', { pipeId: 'abc', mode: 'linear', role: 'start', assignees: ['a', 'b'], prompt: 'X' }),
+ sysMsg('handoff', { pipeId: 'abc', mode: 'linear', role: 'handoff', stage: 1, targetAssignee: 'a' }),
+ ];
+ const state = derivePipeState(messages, 'abc')!;
+ expect(matchResponse(state, 'z')).toBeNull();
+ });
+});
+
+
diff --git a/src/apps/chat/services/pipe-reducer.ts b/src/apps/chat/services/pipe-reducer.ts
new file mode 100644
index 0000000..b34723a
--- /dev/null
+++ b/src/apps/chat/services/pipe-reducer.ts
@@ -0,0 +1,424 @@
+import { randomUUID } from 'crypto';
+import type { ChatMessage, PipeMode, PipeRole, PipeMessageMeta, PipeStatus } from '../types.js';
+import type { ParsedPipeCommand } from './pipe-parser.js';
+import type { StoredPipe } from './pipe-store.js';
+
+// ── Reducer state ────────────────────────────────────────────────────────────
+
+export interface PipeState {
+ pipeId: string;
+ mode: PipeMode;
+ status: PipeStatus;
+ assignees: string[]; // ordered list from start message
+ prompt: string;
+ /** Linear: which stage-output roles exist (by stage number). */
+ stageOutputs: Map;
+ /** Merge: which fan-out roles exist (by assignee name). */
+ fanOutOutputs: Map;
+ /** Roles already emitted by system (for idempotency). */
+ emittedRoles: Set; // keys like "handoff:2", "synth-request", "failed"
+ hasHandoffs: Set; // stage numbers that have handoff messages
+ hasSynthRequest: boolean;
+ hasFinal: boolean;
+ hasFailed: boolean;
+ hasCancelled: boolean;
+}
+
+/** Unique key for idempotency checks on system emissions. */
+function emissionKey(role: PipeRole, stage?: number, targetAssignee?: string): string {
+ if (stage !== undefined) return `${role}:${stage}`;
+ if (targetAssignee) return `${role}:${targetAssignee}`;
+ return role;
+}
+
+// ── Scan log to derive pipe state ────────────────────────────────────────────
+// Note: buildStateFromStore (pipe store) is the primary state source in production.
+// This function is used as a fallback for legacy/recovery pipes not in the store.
+
+export function derivePipeState(messages: ChatMessage[], pipeId: string): PipeState | null {
+ let state: PipeState | null = null;
+
+ for (const msg of messages) {
+ if (!msg.pipe || msg.pipe.pipeId !== pipeId) continue;
+ const p = msg.pipe;
+
+ if (p.role === 'start') {
+ state = {
+ pipeId,
+ mode: p.mode,
+ status: 'running',
+ assignees: p.assignees ?? [],
+ prompt: p.prompt ?? msg.body,
+ stageOutputs: new Map(),
+ fanOutOutputs: new Map(),
+ emittedRoles: new Set(),
+ hasHandoffs: new Set(),
+ hasSynthRequest: false,
+ hasFinal: false,
+ hasFailed: false,
+ hasCancelled: false,
+ };
+ continue;
+ }
+
+ if (!state) continue;
+
+ switch (p.role) {
+ case 'handoff':
+ if (p.stage !== undefined) state.hasHandoffs.add(p.stage);
+ state.emittedRoles.add(emissionKey('handoff', p.stage));
+ break;
+ case 'fan-out-request':
+ state.emittedRoles.add(emissionKey('fan-out-request', undefined, p.targetAssignee));
+ break;
+ case 'stage-output':
+ if (p.stage !== undefined) {
+ state.stageOutputs.set(p.stage, { from: msg.from, body: msg.body });
+ }
+ break;
+ case 'fan-out':
+ state.fanOutOutputs.set(msg.from, msg.body);
+ break;
+ case 'synth-request':
+ state.hasSynthRequest = true;
+ state.emittedRoles.add('synth-request');
+ break;
+ case 'final':
+ state.hasFinal = true;
+ state.status = 'completed';
+ break;
+ case 'assignee-unavailable':
+ // Don't set status yet — 'failed' message does that
+ break;
+ case 'failed':
+ state.hasFailed = true;
+ state.status = 'failed';
+ break;
+ case 'cancelled':
+ state.hasCancelled = true;
+ state.status = 'cancelled';
+ break;
+ }
+ }
+
+ return state;
+}
+
+/** Build PipeState directly from the pipe store — no log scanning needed.
+ * This is the primary state builder for store-tracked pipes. */
+export function buildStateFromStore(pipe: StoredPipe): PipeState {
+ const state: PipeState = {
+ pipeId: pipe.pipeId,
+ mode: pipe.mode,
+ status: pipe.status === 'running' ? 'running' : pipe.status,
+ assignees: pipe.assignees,
+ prompt: pipe.prompt,
+ stageOutputs: new Map(),
+ fanOutOutputs: new Map(),
+ emittedRoles: new Set(),
+ hasHandoffs: new Set(pipe.emittedHandoffs),
+ hasSynthRequest: pipe.emittedSynthRequest,
+ hasFinal: false,
+ hasFailed: pipe.status === 'failed',
+ hasCancelled: pipe.status === 'cancelled',
+ };
+
+ // Populate emittedRoles from store tracking
+ for (const stage of pipe.emittedHandoffs) {
+ state.emittedRoles.add(`handoff:${stage}`);
+ }
+ for (const assignee of pipe.emittedFanOutRequests) {
+ state.emittedRoles.add(`fan-out-request:${assignee}`);
+ }
+ if (pipe.emittedSynthRequest) {
+ state.emittedRoles.add('synth-request');
+ }
+
+ // Populate outputs from store slots
+ for (const [assignee, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.status === 'submitted' && slot.content) {
+ if (slot.stage !== undefined && slot.role !== 'final') {
+ state.stageOutputs.set(slot.stage, { from: assignee, body: slot.content });
+ }
+ if (slot.role === 'fan-out') {
+ state.fanOutOutputs.set(assignee, slot.content);
+ }
+ if (slot.role === 'final') {
+ state.hasFinal = true;
+ }
+ }
+ }
+ }
+
+ return state;
+}
+
+// ── Reducer: compute next actions ────────────────────────────────────────────
+
+export interface PipeAction {
+ type: 'handoff' | 'fan-out-request' | 'synth-request';
+ targetAssignee: string;
+ stage?: number;
+ body: string;
+ pipe: PipeMessageMeta;
+}
+
+function isMergeAllStyleMode(mode: PipeMode): boolean {
+ return mode === 'merge-all' || mode === 'explain' || mode === 'summarize';
+}
+
+export function computeNextActions(state: PipeState): PipeAction[] {
+ // Guard: terminal state → no actions
+ if (state.hasFinal || state.hasFailed || state.hasCancelled) return [];
+
+ if (state.mode === 'linear') return computeLinearActions(state);
+ if (state.mode === 'merge' || isMergeAllStyleMode(state.mode)) return computeMergeActions(state);
+ return [];
+}
+
+function computeLinearActions(state: PipeState): PipeAction[] {
+ const actions: PipeAction[] = [];
+ const totalStages = state.assignees.length;
+
+ // Check if stage 1 handoff needs to be emitted (initial delivery)
+ if (!state.hasHandoffs.has(1)) {
+ const target = state.assignees[0];
+ actions.push({
+ type: 'handoff',
+ targetAssignee: target,
+ stage: 1,
+ body: formatLinearHandoff(state, 1),
+ pipe: {
+ pipeId: state.pipeId,
+ mode: 'linear',
+ role: 'handoff',
+ stage: 1,
+ targetAssignee: target,
+ expectedAssignees: [target],
+ },
+ });
+ return actions; // Only emit one action at a time
+ }
+
+ // Check each stage: if output exists and next handoff missing → emit
+ for (let stage = 1; stage < totalStages; stage++) {
+ const output = state.stageOutputs.get(stage);
+ if (!output) continue; // stage not responded yet
+
+ const nextStage = stage + 1;
+ const key = emissionKey('handoff', nextStage);
+ if (state.emittedRoles.has(key)) continue; // already emitted
+
+ const target = state.assignees[nextStage - 1];
+ const isLast = nextStage === totalStages;
+ actions.push({
+ type: 'handoff',
+ targetAssignee: target,
+ stage: nextStage,
+ body: formatLinearHandoff(state, nextStage),
+ pipe: {
+ pipeId: state.pipeId,
+ mode: 'linear',
+ role: 'handoff',
+ stage: nextStage,
+ targetAssignee: target,
+ expectedAssignees: [target],
+ },
+ });
+ return actions; // One action at a time for linear
+ }
+
+ return actions;
+}
+
+function computeMergeActions(state: PipeState): PipeAction[] {
+ const actions: PipeAction[] = [];
+ const isMergeAll = isMergeAllStyleMode(state.mode);
+ const fanOutAssignees = isMergeAll ? state.assignees : state.assignees.slice(0, -1);
+ const synthesizer = state.assignees[state.assignees.length - 1];
+
+ // Check if fan-out requests need to be emitted
+ for (const assignee of fanOutAssignees) {
+ const key = emissionKey('fan-out-request', undefined, assignee);
+ if (state.emittedRoles.has(key)) continue;
+ actions.push({
+ type: 'fan-out-request',
+ targetAssignee: assignee,
+ body: formatFanOutRequest(state, assignee),
+ pipe: {
+ pipeId: state.pipeId,
+ mode: state.mode,
+ role: 'fan-out-request',
+ targetAssignee: assignee,
+ expectedAssignees: fanOutAssignees,
+ },
+ });
+ }
+ if (actions.length > 0) return actions; // Emit fan-out first
+
+ // Check if all fan-out replies are in and synth-request is missing
+ const allFanOutDone = fanOutAssignees.every(a => state.fanOutOutputs.has(a));
+ if (allFanOutDone && !state.hasSynthRequest) {
+ actions.push({
+ type: 'synth-request',
+ targetAssignee: synthesizer,
+ body: formatSynthRequest(state),
+ pipe: {
+ pipeId: state.pipeId,
+ mode: state.mode,
+ role: 'synth-request',
+ targetAssignee: synthesizer,
+ expectedAssignees: [synthesizer],
+ },
+ });
+ }
+
+ return actions;
+}
+
+// ── Formatting helpers ───────────────────────────────────────────────────────
+
+function submitBlock(pipeId: string): string {
+ return `Submit: pipe_submit(pipeId="${pipeId}", content="")\nDo not use chat_send. Submit once, then wait.`;
+}
+
+function formatLinearHandoff(state: PipeState, stage: number): string {
+ const target = state.assignees[stage - 1];
+ const total = state.assignees.length;
+ const isLast = stage === total;
+ const header = `#pipe-${state.pipeId} [linear | stage ${stage}/${total} | @${target}]`;
+
+ const dest = isLast ? 'Final stage — your response goes to the user.' : 'Your output passes to the next stage.';
+ let body = `${dest}\nPrompt: ${state.prompt}`;
+ if (stage > 1) {
+ body += `\n\nRead previous stage output: pipe_read_output(pipeId="${state.pipeId}")`;
+ }
+
+ return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`;
+}
+
+function formatFanOutRequest(state: PipeState, assignee: string): string {
+ const header = `#pipe-${state.pipeId} [${state.mode} | fan-out | @${assignee}]`;
+ const mergeAll = isMergeAllStyleMode(state.mode);
+ const synthesizer = state.assignees[state.assignees.length - 1];
+ const isSynthesizer = mergeAll && assignee === synthesizer;
+
+ let body: string;
+ if (state.mode === 'explain') {
+ body = `Explain independently (parallel). Respond with:
+1. Problem 2. Simplest explanation 3. Mental model (≤5 steps)
+4. Visual (Mermaid/ASCII/"No visual needed") 5. Key terms (≤5)
+6. Common misunderstanding 7. Takeaway
+Teach a smart beginner. Clarity over exhaustiveness.`;
+ } else if (state.mode === 'summarize') {
+ body = `Summarize independently (parallel). Respond with:
+1. Topic 2. Key points (3–5 bullets) 3. Why it matters 4. TL;DR (1–2 sentences)
+Compress, cut repetition, minimize jargon.`;
+ } else {
+ body = `Provide your independent analysis. Other participants answer in parallel.`;
+ }
+
+ body += `\nPrompt: ${state.prompt}`;
+ if (isSynthesizer) {
+ body += `\n\nYou have 2 stages. This is fan-out — submit your analysis now. Synthesis comes next.`;
+ }
+
+ return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`;
+}
+
+function formatSynthRequest(state: PipeState): string {
+ const synthesizer = state.assignees[state.assignees.length - 1];
+ const header = `#pipe-${state.pipeId} [${state.mode} | synthesizer | @${synthesizer}]`;
+
+ let body: string;
+ if (state.mode === 'explain') {
+ body = `Synthesize into one teaching response. Sections:
+1. Problem 2. Simplest explanation 3. Mental model 4. Visual
+5. Key terms 6. Common misunderstandings 7. Takeaway
+Pick the clearest framing. At most one visual (Mermaid preferred).`;
+ } else if (state.mode === 'summarize') {
+ body = `Synthesize into one compact summary. Sections:
+1. TL;DR 2. Key points 3. Why it matters 4. Caveat (only if important)
+Pick the clearest framing. Drop redundant points.`;
+ } else {
+ body = `Synthesize the fan-out outputs into a unified response for the user.`;
+ }
+
+ body += `\nPrompt: ${state.prompt}`;
+ body += `\n\nRead all fan-out outputs: pipe_read_output(pipeId="${state.pipeId}")`;
+
+ return `${header}\n\n${body}\n\n${submitBlock(state.pipeId)}`;
+}
+
+// ── Pipe start description ───────────────────────────────────────────────────
+
+export function generatePipeId(): string {
+ return randomUUID().substring(0, 8);
+}
+
+export function getStartDescription(cmd: ParsedPipeCommand): string {
+ if (cmd.mode === 'linear') {
+ return cmd.assignees.map(a => `@${a}`).join(' \u2192 ');
+ }
+ const isMergeAll = isMergeAllStyleMode(cmd.mode);
+ const fanOutList = isMergeAll ? cmd.assignees : cmd.assignees.slice(0, -1);
+ const fanOut = fanOutList.map(a => `@${a}`).join(', ');
+ const synthesizer = `@${cmd.assignees[cmd.assignees.length - 1]}`;
+ return `[${fanOut}] \u2192 ${synthesizer}`;
+}
+
+/**
+ * Determine the pipe role for an LLM response based on the current pipe state.
+ * Returns the appropriate PipeMessageMeta if the sender is an expected assignee,
+ * or null if the message is not a pipe response.
+ */
+export function matchResponse(
+ state: PipeState,
+ from: string,
+): PipeMessageMeta | null {
+ if (state.status !== 'running') return null;
+
+ if (state.mode === 'linear') {
+ // Find which stage is expecting a response from this sender
+ for (let stage = 1; stage <= state.assignees.length; stage++) {
+ if (state.assignees[stage - 1] !== from) continue;
+ if (state.stageOutputs.has(stage)) continue; // already responded
+ if (!state.hasHandoffs.has(stage)) continue; // not yet prompted
+
+ const isLast = stage === state.assignees.length;
+ return {
+ pipeId: state.pipeId,
+ mode: 'linear',
+ role: isLast ? 'final' : 'stage-output',
+ stage,
+ };
+ }
+ }
+
+ if (state.mode === 'merge' || isMergeAllStyleMode(state.mode)) {
+ const isMergeAll = isMergeAllStyleMode(state.mode);
+ const fanOutAssignees = isMergeAll ? state.assignees : state.assignees.slice(0, -1);
+ const synthesizer = state.assignees[state.assignees.length - 1];
+
+ // Fan-out response
+ if (fanOutAssignees.includes(from) && !state.fanOutOutputs.has(from)) {
+ return {
+ pipeId: state.pipeId,
+ mode: state.mode,
+ role: 'fan-out',
+ };
+ }
+
+ // Synthesizer response
+ if (from === synthesizer && state.hasSynthRequest && !state.hasFinal) {
+ return {
+ pipeId: state.pipeId,
+ mode: state.mode,
+ role: 'final',
+ };
+ }
+ }
+
+ return null;
+}
diff --git a/src/apps/chat/services/pipe-reliability.test.ts b/src/apps/chat/services/pipe-reliability.test.ts
new file mode 100644
index 0000000..e5c8a25
--- /dev/null
+++ b/src/apps/chat/services/pipe-reliability.test.ts
@@ -0,0 +1,464 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as pipeStore from './pipe-store.js';
+import * as pipeReducer from './pipe-reducer.js';
+import { parsePipeCommand, parseDuration } from './pipe-parser.js';
+
+beforeEach(() => {
+ pipeStore._resetForTest();
+});
+
+// ── parseDuration ────────────────────────────────────────────────────────────
+
+describe('parseDuration', () => {
+ it('parses seconds', () => {
+ expect(parseDuration('30s')).toBe(30_000);
+ });
+
+ it('parses minutes', () => {
+ expect(parseDuration('5m')).toBe(300_000);
+ });
+
+ it('parses hours', () => {
+ expect(parseDuration('1h')).toBe(3_600_000);
+ });
+
+ it('returns null for invalid input', () => {
+ expect(parseDuration('abc')).toBeNull();
+ expect(parseDuration('5x')).toBeNull();
+ expect(parseDuration('')).toBeNull();
+ expect(parseDuration('0s')).toBeNull();
+ });
+
+ it('returns null for negative or zero values', () => {
+ expect(parseDuration('0m')).toBeNull();
+ });
+});
+
+// ── Pipe command flag parsing ────────────────────────────────────────────────
+
+describe('pipe-parser timeout flags', () => {
+ it('parses --timeout flag', () => {
+ const result = parsePipeCommand('/linear-pipe --timeout 10m @alice @bob do something');
+ expect(result).toMatchObject({
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'do something',
+ stageTimeoutMs: 600_000,
+ });
+ });
+
+ it('parses --on-timeout flag', () => {
+ const result = parsePipeCommand('/linear-pipe --on-timeout escalate @alice @bob do something');
+ expect(result).toMatchObject({
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'do something',
+ timeoutPolicy: 'escalate',
+ });
+ });
+
+ it('parses both flags together', () => {
+ const result = parsePipeCommand('/merge-all-pipe --timeout 30s --on-timeout fail @alice @bob analyze this');
+ expect(result).toMatchObject({
+ mode: 'merge-all',
+ stageTimeoutMs: 30_000,
+ timeoutPolicy: 'fail',
+ assignees: ['alice', 'bob'],
+ prompt: 'analyze this',
+ });
+ });
+
+ it('rejects invalid timeout duration', () => {
+ const result = parsePipeCommand('/linear-pipe --timeout xyz @alice @bob do something');
+ expect(result).toHaveProperty('error');
+ expect((result as { error: string }).error).toContain('Invalid timeout duration');
+ });
+
+ it('rejects invalid timeout policy', () => {
+ const result = parsePipeCommand('/linear-pipe --on-timeout destroy @alice @bob do something');
+ expect(result).toHaveProperty('error');
+ expect((result as { error: string }).error).toContain('Invalid timeout policy');
+ });
+
+ it('accepts commands without flags (backward compat)', () => {
+ const result = parsePipeCommand('/linear-pipe @alice @bob do something');
+ expect(result).toMatchObject({
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'do something',
+ });
+ expect(result).not.toHaveProperty('stageTimeoutMs');
+ expect(result).not.toHaveProperty('timeoutPolicy');
+ });
+
+ it('stops flag parsing at unknown flags (treats as prompt)', () => {
+ const result = parsePipeCommand('/linear-pipe --unknown value do something');
+ expect(result).toMatchObject({
+ mode: 'linear',
+ assignees: [],
+ prompt: '--unknown value do something',
+ });
+ });
+});
+
+// ── Pipe creation with timeout config ────────────────────────────────────────
+
+describe('pipe-store timeout config', () => {
+ it('creates a pipe with default timeout', () => {
+ const pipe = pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ expect(pipe.stageTimeoutMs).toBe(pipeStore.DEFAULT_STAGE_TIMEOUT_MS);
+ expect(pipe.timeoutPolicy).toBe('fail');
+ });
+
+ it('creates a pipe with custom timeout', () => {
+ const pipe = pipeStore.createPipe('pipe-2', 'linear', ['alice', 'bob'], 'test', 'proj-1', {
+ stageTimeoutMs: 30_000,
+ timeoutPolicy: 'escalate',
+ });
+ expect(pipe.stageTimeoutMs).toBe(30_000);
+ expect(pipe.timeoutPolicy).toBe('escalate');
+ });
+
+ it('creates a pipe with zero timeout (disabled)', () => {
+ const pipe = pipeStore.createPipe('pipe-3', 'linear', ['alice', 'bob'], 'test', 'proj-1', {
+ stageTimeoutMs: 0,
+ });
+ expect(pipe.stageTimeoutMs).toBe(0);
+ });
+});
+
+// ── Lease deadline ───────────────────────────────────────────────────────────
+
+describe('pipe-store lease deadline', () => {
+ it('sets deadline on lease when timeout is configured', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1', {
+ stageTimeoutMs: 60_000,
+ });
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.lease).toBeDefined();
+ expect(result.lease!.deadline).toBeDefined();
+ expect(result.lease!.deadline).not.toBeNull();
+
+ // Deadline should be ~60s from now
+ const deadline = new Date(result.lease!.deadline!).getTime();
+ const now = Date.now();
+ expect(deadline - now).toBeGreaterThan(55_000);
+ expect(deadline - now).toBeLessThan(65_000);
+ });
+
+ it('sets no deadline when timeout is 0', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1', {
+ stageTimeoutMs: 0,
+ });
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.lease!.deadline).toBeNull();
+ });
+});
+
+// ── getAllActiveLeases ────────────────────────────────────────────────────────
+
+describe('pipe-store getAllActiveLeases', () => {
+ it('returns empty map when no leases', () => {
+ expect(pipeStore.getAllActiveLeases().size).toBe(0);
+ });
+
+ it('returns active leases after grant', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const leases = pipeStore.getAllActiveLeases();
+ expect(leases.size).toBe(1);
+ const lease = [...leases.values()][0];
+ expect(lease.pipeId).toBe('pipe-1');
+ expect(lease.assignee).toBe('alice');
+ });
+
+ it('removes lease after submit', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true);
+ expect(pipeStore.getAllActiveLeases().size).toBe(0);
+ });
+});
+
+// ── Terminal pipe cleanup ────────────────────────────────────────────────────
+
+describe('pipe-store cleanupTerminalPipes', () => {
+ it('does not remove running pipes', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ const removed = pipeStore.cleanupTerminalPipes('proj-1', 0); // TTL=0 means remove everything
+ expect(removed).toEqual([]);
+ });
+
+ it('removes completed pipes older than TTL', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.markPipeStatus('pipe-1', 'completed', 'proj-1');
+ const removed = pipeStore.cleanupTerminalPipes('proj-1', 0);
+ expect(removed).toEqual(['pipe-1']);
+ expect(pipeStore.getPipe('pipe-1', 'proj-1')).toBeUndefined();
+ });
+
+ it('removes failed pipes older than TTL', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1');
+ const removed = pipeStore.cleanupTerminalPipes('proj-1', 0);
+ expect(removed).toEqual(['pipe-1']);
+ });
+
+ it('removes cancelled pipes older than TTL', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1');
+ const removed = pipeStore.cleanupTerminalPipes('proj-1', 0);
+ expect(removed).toEqual(['pipe-1']);
+ });
+
+ it('does not remove terminal pipes within TTL', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'test', 'proj-1');
+ pipeStore.markPipeStatus('pipe-1', 'completed', 'proj-1');
+ const removed = pipeStore.cleanupTerminalPipes('proj-1', 999_999_999); // huge TTL
+ expect(removed).toEqual([]);
+ });
+});
+
+// ── Rehydration from events ──────────────────────────────────────────────────
+
+describe('pipe-store rehydrateFromEvents', () => {
+ it('recreates a pipe from start event', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ {
+ type: 'start',
+ pipeId: 'pipe-r1',
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'test prompt',
+ stageTimeoutMs: 60_000,
+ timeoutPolicy: 'escalate',
+ },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual(['pipe-r1']);
+
+ const pipe = pipeStore.getPipe('pipe-r1', 'proj-1');
+ expect(pipe).toBeDefined();
+ expect(pipe!.mode).toBe('linear');
+ expect(pipe!.status).toBe('running');
+ expect(pipe!.assignees).toEqual(['alice', 'bob']);
+ expect(pipe!.stageTimeoutMs).toBe(60_000);
+ expect(pipe!.timeoutPolicy).toBe('escalate');
+ });
+
+ it('replays submissions', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ {
+ type: 'start',
+ pipeId: 'pipe-r2',
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'test',
+ },
+ {
+ type: 'stage-output',
+ pipeId: 'pipe-r2',
+ from: 'alice',
+ content: 'alice output',
+ },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual(['pipe-r2']);
+
+ const pipe = pipeStore.getPipe('pipe-r2', 'proj-1');
+ expect(pipe).toBeDefined();
+ const aliceSlot = pipe!.slots.get('alice')![0];
+ expect(aliceSlot.status).toBe('submitted');
+ expect(aliceSlot.content).toBe('alice output');
+ });
+
+ it('marks terminal pipes correctly', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ {
+ type: 'start',
+ pipeId: 'pipe-r3',
+ mode: 'merge-all',
+ assignees: ['alice', 'bob'],
+ prompt: 'test',
+ },
+ { type: 'complete', pipeId: 'pipe-r3' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual([]);
+
+ const pipe = pipeStore.getPipe('pipe-r3', 'proj-1');
+ expect(pipe).toBeDefined();
+ expect(pipe!.status).toBe('completed');
+ });
+
+ it('marks failed pipes correctly', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ {
+ type: 'start',
+ pipeId: 'pipe-r4',
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'test',
+ },
+ { type: 'failed', pipeId: 'pipe-r4' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual([]);
+
+ const pipe = pipeStore.getPipe('pipe-r4', 'proj-1');
+ expect(pipe!.status).toBe('failed');
+ });
+
+ it('marks cancelled pipes correctly', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ {
+ type: 'start',
+ pipeId: 'pipe-r5',
+ mode: 'linear',
+ assignees: ['alice', 'bob'],
+ prompt: 'test',
+ },
+ { type: 'cancel', pipeId: 'pipe-r5' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual([]);
+
+ const pipe = pipeStore.getPipe('pipe-r5', 'proj-1');
+ expect(pipe!.status).toBe('cancelled');
+ });
+
+ it('skips events without a start event', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'stage-output', pipeId: 'pipe-orphan', from: 'alice', content: 'output' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual([]);
+ expect(pipeStore.getPipe('pipe-orphan', 'proj-1')).toBeUndefined();
+ });
+
+ it('handles multiple pipes in one batch', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'p1', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'first' },
+ { type: 'start', pipeId: 'p2', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'second' },
+ { type: 'stage-output', pipeId: 'p1', from: 'alice', content: 'done' },
+ { type: 'complete', pipeId: 'p2' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual(['p1']);
+
+ expect(pipeStore.getPipe('p1', 'proj-1')!.status).toBe('running');
+ expect(pipeStore.getPipe('p2', 'proj-1')!.status).toBe('completed');
+ });
+
+ it('does not duplicate pipes already in store', () => {
+ pipeStore.createPipe('existing', 'linear', ['alice', 'bob'], 'already here', 'proj-1');
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'existing', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'duplicate' },
+ ];
+ const running = pipeStore.rehydrateFromEvents(events, 'proj-1');
+ expect(running).toEqual([]);
+ // Original prompt should be preserved
+ expect(pipeStore.getPipe('existing', 'proj-1')!.prompt).toBe('already here');
+ });
+});
+
+// ── Emission state rebuild on recovery ───────────────────────────────────────
+
+describe('pipe-store emission rebuild on recovery', () => {
+ it('rebuilds linear emission state: submitted stage-1 marks handoff-1 as emitted', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'lr1', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'lr1', from: 'alice', content: 'alice output' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('lr1', 'proj-1')!;
+ // Stage 1 (alice) was submitted, so handoff for stage 1 was emitted
+ expect(pipe.emittedHandoffs.has(1)).toBe(true);
+ // Handoff for stage 2 has NOT been emitted yet (bob hasn't started)
+ expect(pipe.emittedHandoffs.has(2)).toBe(false);
+ });
+
+ it('rebuilds linear emission state: two submitted stages mark handoffs 1 and 2 as emitted', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'lr2', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'lr2', from: 'alice', content: 'alice output' },
+ { type: 'stage-output', pipeId: 'lr2', from: 'bob', content: 'bob output' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('lr2', 'proj-1')!;
+ expect(pipe.emittedHandoffs.has(1)).toBe(true);
+ expect(pipe.emittedHandoffs.has(2)).toBe(true);
+ expect(pipe.emittedHandoffs.has(3)).toBe(false);
+ });
+
+ it('rebuilds merge-all emission state: submitted fan-out marks fan-out request as emitted', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'mr1', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'mr1', from: 'alice', content: 'alice analysis' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('mr1', 'proj-1')!;
+ expect(pipe.emittedFanOutRequests.has('alice')).toBe(true);
+ // Bob's fan-out was not submitted, but it should still be marked if we're recovering
+ // (the fan-out request was sent to both participants at pipe start)
+ // Actually, bob's fan-out slot is still pending, so the request may or may not have been emitted.
+ // The safest approach: bob's fan-out is pending, so it's NOT marked as emitted.
+ // The reducer will re-emit it, which is correct — bob didn't respond.
+ expect(pipe.emittedFanOutRequests.has('bob')).toBe(false);
+ expect(pipe.emittedSynthRequest).toBe(false);
+ });
+
+ it('rebuilds merge-all emission state: all fan-outs submitted but synth not started', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'mr2', mode: 'merge-all', assignees: ['alice', 'bob'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'mr2', from: 'alice', content: 'alice analysis' },
+ // bob is the synthesizer in merge-all, his fan-out slot submitted too
+ { type: 'stage-output', pipeId: 'mr2', from: 'bob', content: 'bob analysis' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('mr2', 'proj-1')!;
+ expect(pipe.emittedFanOutRequests.has('alice')).toBe(true);
+ expect(pipe.emittedFanOutRequests.has('bob')).toBe(true);
+ // All fan-outs submitted but the final (synth) slot for bob is still pending
+ // So synth request has NOT been emitted yet
+ expect(pipe.emittedSynthRequest).toBe(false);
+ });
+
+ it('rebuilds no emissions for a pipe with no submissions', () => {
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'empty1', mode: 'linear', assignees: ['alice', 'bob'], prompt: 'test' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('empty1', 'proj-1')!;
+ expect(pipe.emittedHandoffs.size).toBe(0);
+ expect(pipe.emittedFanOutRequests.size).toBe(0);
+ expect(pipe.emittedSynthRequest).toBe(false);
+ });
+
+ it('linear recovery resumes at correct stage (does not re-emit submitted handoffs)', () => {
+ // Simulate: 3-stage linear pipe where stage 1 (alice) already submitted
+ // After recovery, the reducer should only emit handoff for stage 2, not stage 1
+ const events: pipeStore.PipeRecoveryEvent[] = [
+ { type: 'start', pipeId: 'resume1', mode: 'linear', assignees: ['alice', 'bob', 'carol'], prompt: 'test' },
+ { type: 'stage-output', pipeId: 'resume1', from: 'alice', content: 'alice done' },
+ ];
+ pipeStore.rehydrateFromEvents(events, 'proj-1');
+ const pipe = pipeStore.getPipe('resume1', 'proj-1')!;
+
+ // Verify emission state prevents duplicate handoffs
+ expect(pipe.emittedHandoffs.has(1)).toBe(true); // stage 1 already happened
+ expect(pipe.emittedHandoffs.has(2)).toBe(false); // stage 2 not yet
+
+ // Now simulate what the reducer would do
+ const state = pipeReducer.buildStateFromStore(pipe);
+ const actions = pipeReducer.computeNextActions(state);
+
+ // Should emit exactly one action: handoff for stage 2 (bob)
+ expect(actions).toHaveLength(1);
+ expect(actions[0].targetAssignee).toBe('bob');
+ expect(actions[0].type).toBe('handoff');
+ expect(actions[0].stage).toBe(2);
+ });
+});
diff --git a/src/apps/chat/services/pipe-sidebar-monitor.test.ts b/src/apps/chat/services/pipe-sidebar-monitor.test.ts
new file mode 100644
index 0000000..b019abf
--- /dev/null
+++ b/src/apps/chat/services/pipe-sidebar-monitor.test.ts
@@ -0,0 +1,293 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as pipeStore from './pipe-store.js';
+import * as provenanceStore from './pipe-provenance.js';
+
+/**
+ * Focused verification for the pipe monitoring sidebar.
+ *
+ * Tests the data layer patterns that the sidebar UI relies on:
+ * - Initial load (listAllPipes + getRuntimeLeaseStatuses + getDeadLetterEntries)
+ * - Drilldown (getPipeStatus + getPipeTimingSummary)
+ * - Cancel action (cancelPipe + status transition)
+ * - Representative render states (running, completed, failed, cancelled, dead-lettered)
+ */
+
+const projectId = 'sidebar-test';
+
+beforeEach(() => {
+ pipeStore._resetForTest();
+ provenanceStore._resetForTest();
+});
+
+// ── Initial Load ─────────────────────────────────────────────────────────────
+
+describe('Sidebar initial load', () => {
+ it('returns all pipes with slot summaries for the pipe list', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'prompt1', projectId);
+ pipeStore.createPipe('p2', 'merge', ['alice', 'bob', 'carol'], 'prompt2', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all).toHaveLength(2);
+
+ const p1 = all.find(p => p.pipeId === 'p1')!;
+ expect(p1.mode).toBe('linear');
+ expect(p1.status).toBe('running');
+ expect(p1.slotSummary).toBeDefined();
+ expect(p1.slotSummary.total).toBe(2);
+ expect(p1.slotSummary.pending).toBe(2);
+ expect(p1.slotSummary.submitted).toBe(0);
+
+ const p2 = all.find(p => p.pipeId === 'p2')!;
+ expect(p2.mode).toBe('merge');
+ expect(p2.slotSummary.total).toBe(3);
+ });
+
+ it('returns lease statuses for countdown badges', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, {
+ stageTimeoutMs: 60000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ const leases = pipeStore.getRuntimeLeaseStatuses(projectId);
+ expect(leases).toHaveLength(1);
+ expect(leases[0]).toMatchObject({
+ pipeId: 'p1',
+ assignee: 'alice',
+ isOverdue: false,
+ });
+ expect(leases[0].deadline).toBeDefined();
+ expect(leases[0].remainingMs).toBeGreaterThan(0);
+ expect(leases[0].elapsedMs).toBeGreaterThanOrEqual(0);
+ });
+
+ it('returns empty dead-letters for healthy pipes', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, {
+ stageTimeoutMs: 300000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+ expect(pipeStore.getDeadLetterEntries(projectId)).toHaveLength(0);
+ });
+
+ it('isolates pipe data by project', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.createPipe('p2', 'linear', ['bob'], 'test', 'other-project');
+
+ expect(pipeStore.listAllPipes(projectId)).toHaveLength(1);
+ expect(pipeStore.listAllPipes('other-project')).toHaveLength(1);
+ });
+});
+
+// ── Drilldown ────────────────────────────────────────────────────────────────
+
+describe('Sidebar drilldown', () => {
+ it('returns detailed pipe status with slots and prompt', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'my prompt', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ const status = pipeStore.getPipeStatus('p1', projectId);
+ expect(status).toBeDefined();
+ expect(status!.prompt).toBe('my prompt');
+ expect(status!.slots).toHaveLength(2);
+ expect(status!.assignees).toEqual(['alice', 'bob']);
+
+ const aliceSlot = status!.slots.find(s => s.assignee === 'alice');
+ expect(aliceSlot?.role).toBe('stage-output');
+ expect(aliceSlot?.status).toBe('leased');
+
+ const bobSlot = status!.slots.find(s => s.assignee === 'bob');
+ expect(bobSlot?.status).toBe('pending');
+ });
+
+ it('returns timing summary for completed pipes', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'output-a', projectId, true);
+ pipeStore.grantLease('p1', 'bob', projectId);
+ pipeStore.submitStage('p1', 'bob', 'output-b', projectId, true);
+ pipeStore.markPipeStatus('p1', 'completed', projectId);
+
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing).toBeDefined();
+ expect(timing!.status).toBe('completed');
+ expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0);
+ expect(timing!.stages).toHaveLength(2);
+ expect(timing!.stages.every(s => s.submittedAt !== null)).toBe(true);
+ expect(timing!.stages.every(s => s.durationMs === null || typeof s.durationMs === 'number')).toBe(true);
+ });
+
+ it('returns undefined for non-existent pipe', () => {
+ expect(pipeStore.getPipeStatus('no-such-pipe', projectId)).toBeUndefined();
+ expect(pipeStore.getPipeTimingSummary('no-such-pipe', projectId)).toBeUndefined();
+ });
+});
+
+// ── Cancel Action ────────────────────────────────────────────────────────────
+
+describe('Sidebar cancel action', () => {
+ it('transitions pipe from running to cancelled', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ pipeStore.markPipeStatus('p1', 'cancelled', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('cancelled');
+
+ // Leases should be cleared after cancellation
+ const leases = pipeStore.getRuntimeLeaseStatuses(projectId);
+ expect(leases).toHaveLength(0);
+ });
+
+ it('cancel is idempotent for already-terminal pipes', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.markPipeStatus('p1', 'completed', projectId);
+
+ // Re-marking as cancelled on an already-completed pipe should not crash
+ pipeStore.markPipeStatus('p1', 'cancelled', projectId);
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('cancelled');
+ });
+});
+
+// ── Representative Render States ─────────────────────────────────────────────
+
+describe('Sidebar render states', () => {
+ it('running pipe: has leases, pending slots, no timing', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId, {
+ stageTimeoutMs: 60000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('running');
+ expect(all[0].slotSummary.leased).toBe(1);
+ expect(all[0].slotSummary.pending).toBe(1);
+
+ const leases = pipeStore.getRuntimeLeaseStatuses(projectId);
+ expect(leases.length).toBeGreaterThan(0);
+
+ // Timing not yet available (pipe still running)
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing!.completedAt).toBeNull();
+ expect(timing!.totalDurationMs).toBeNull();
+ });
+
+ it('completed pipe: all slots submitted, timing available', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'done', projectId, true);
+ pipeStore.markPipeStatus('p1', 'completed', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('completed');
+ expect(all[0].slotSummary.submitted).toBe(1);
+
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing!.completedAt).toBeDefined();
+ expect(timing!.totalDurationMs).toBeGreaterThanOrEqual(0);
+
+ // No active leases
+ expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(0);
+ });
+
+ it('failed pipe: status transitions, timing captures partial work', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'partial', projectId, true);
+ pipeStore.markPipeStatus('p1', 'failed', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('failed');
+
+ const timing = pipeStore.getPipeTimingSummary('p1', projectId);
+ expect(timing!.status).toBe('failed');
+
+ // Alice submitted, bob never did
+ const aliceStage = timing!.stages.find(s => s.assignee === 'alice');
+ const bobStage = timing!.stages.find(s => s.assignee === 'bob');
+ expect(aliceStage?.submittedAt).not.toBeNull();
+ expect(bobStage?.submittedAt).toBeNull();
+ });
+
+ it('cancelled pipe: visible in pipe list with cancelled status', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.markPipeStatus('p1', 'cancelled', projectId);
+
+ const all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('cancelled');
+ });
+
+ it('merge pipe with partial fan-out: slot summary reflects progress', () => {
+ pipeStore.createPipe('p1', 'merge', ['alice', 'bob', 'carol'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.grantLease('p1', 'bob', projectId);
+ pipeStore.submitStage('p1', 'alice', 'alice-out', projectId, true);
+
+ const all = pipeStore.listAllPipes(projectId);
+ const p = all[0];
+ expect(p.slotSummary.submitted).toBe(1);
+ expect(p.slotSummary.leased).toBeGreaterThanOrEqual(1);
+ expect(p.slotSummary.total).toBe(3); // alice(fan-out) + bob(fan-out) + carol(final)
+ });
+});
+
+// ── Event-Driven Refresh ─────────────────────────────────────────────────────
+
+describe('Sidebar refresh on state changes', () => {
+ it('slot summary updates after submission', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice', 'bob'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+
+ let before = pipeStore.listAllPipes(projectId)[0];
+ expect(before.slotSummary.submitted).toBe(0);
+
+ pipeStore.submitStage('p1', 'alice', 'output', projectId, true);
+
+ let after = pipeStore.listAllPipes(projectId)[0];
+ expect(after.slotSummary.submitted).toBe(1);
+ });
+
+ it('pipe status reflects completion in list', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId);
+ pipeStore.grantLease('p1', 'alice', projectId);
+ pipeStore.submitStage('p1', 'alice', 'done', projectId, true);
+
+ let all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('running');
+
+ pipeStore.markPipeStatus('p1', 'completed', projectId);
+
+ all = pipeStore.listAllPipes(projectId);
+ expect(all[0].status).toBe('completed');
+ });
+
+ it('leases disappear after submission', () => {
+ pipeStore.createPipe('p1', 'linear', ['alice'], 'test', projectId, {
+ stageTimeoutMs: 60000,
+ });
+ pipeStore.grantLease('p1', 'alice', projectId);
+ expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(1);
+
+ pipeStore.submitStage('p1', 'alice', 'done', projectId, true);
+ expect(pipeStore.getRuntimeLeaseStatuses(projectId)).toHaveLength(0);
+ });
+});
+
+// ── Provenance (optional for MVP — verify basic availability) ────────────────
+
+describe('Sidebar provenance', () => {
+ it('provenance records are queryable per pipe', () => {
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'created', actor: 'user', actorKind: 'user',
+ });
+ provenanceStore.recordProvenance(projectId, {
+ pipeId: 'p1', event: 'stage-granted', actor: 'system', actorKind: 'system',
+ stage: 1, metadata: { assignee: 'alice' },
+ });
+
+ const records = provenanceStore.getProvenanceForPipe('p1', projectId);
+ expect(records).toHaveLength(2);
+ expect(records[0].event).toBe('created');
+ expect(records[1].event).toBe('stage-granted');
+ });
+});
diff --git a/src/apps/chat/services/pipe-store.test.ts b/src/apps/chat/services/pipe-store.test.ts
new file mode 100644
index 0000000..cb3d75a
--- /dev/null
+++ b/src/apps/chat/services/pipe-store.test.ts
@@ -0,0 +1,382 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as pipeStore from './pipe-store.js';
+import * as assignmentQueries from './pipe-assignment-queries.js';
+
+beforeEach(() => {
+ pipeStore._resetForTest();
+});
+
+// ── Helper ────────────────────────────────────────────────────────────────────
+
+function createLinearPipe(assignees = ['alice', 'bob', 'carol']) {
+ return pipeStore.createPipe('pipe-1', 'linear', assignees, 'test prompt', 'proj-1');
+}
+
+function createMergePipe(assignees = ['alice', 'bob', 'carol']) {
+ return pipeStore.createPipe('pipe-2', 'merge', assignees, 'test prompt', 'proj-1');
+}
+
+// ── createPipe ────────────────────────────────────────────────────────────────
+
+describe('pipe-store createPipe', () => {
+ it('creates a linear pipe with correct slots', () => {
+ const pipe = createLinearPipe();
+ expect(pipe.pipeId).toBe('pipe-1');
+ expect(pipe.mode).toBe('linear');
+ expect(pipe.status).toBe('running');
+ expect(pipe.slots.size).toBe(3);
+
+ const alice = pipe.slots.get('alice')![0];
+ expect(alice.role).toBe('stage-output');
+ expect(alice.stage).toBe(1);
+ expect(alice.status).toBe('pending');
+
+ const bob = pipe.slots.get('bob')![0];
+ expect(bob.role).toBe('stage-output');
+ expect(bob.stage).toBe(2);
+
+ const carol = pipe.slots.get('carol')![0];
+ expect(carol.role).toBe('final');
+ expect(carol.stage).toBe(3);
+ });
+
+ it('creates a merge pipe with fan-out and synthesizer slots', () => {
+ const pipe = createMergePipe();
+ expect(pipe.mode).toBe('merge');
+ expect(pipe.slots.size).toBe(3);
+
+ expect(pipe.slots.get('alice')![0].role).toBe('fan-out');
+ expect(pipe.slots.get('bob')![0].role).toBe('fan-out');
+ expect(pipe.slots.get('carol')![0].role).toBe('final');
+ });
+
+ it('creates a merge-all pipe with fan-out for everyone and synthesizer role for last', () => {
+ const pipe = pipeStore.createPipe('pipe-3', 'merge-all', ['alice', 'bob'], 'test', 'proj-1');
+ expect(pipe.mode).toBe('merge-all');
+ expect(pipe.slots.size).toBe(2);
+
+ const alice = pipe.slots.get('alice')!;
+ expect(alice).toHaveLength(1);
+ expect(alice[0].role).toBe('fan-out');
+
+ const bob = pipe.slots.get('bob')!;
+ expect(bob).toHaveLength(2);
+ expect(bob[0].role).toBe('fan-out');
+ expect(bob[1].role).toBe('final');
+ });
+
+ it('creates an explain pipe with merge-all style slots', () => {
+ const pipe = pipeStore.createPipe('pipe-4', 'explain', ['alice', 'bob'], 'teach this', 'proj-1');
+ expect(pipe.mode).toBe('explain');
+ expect(pipe.slots.get('alice')?.map((slot) => slot.role)).toEqual(['fan-out']);
+ expect(pipe.slots.get('bob')?.map((slot) => slot.role)).toEqual(['fan-out', 'final']);
+ });
+
+ it('creates a summarize pipe with merge-all style slots', () => {
+ const pipe = pipeStore.createPipe('pipe-5', 'summarize', ['alice', 'bob'], 'digest this', 'proj-1');
+ expect(pipe.mode).toBe('summarize');
+ expect(pipe.slots.get('alice')?.map((slot) => slot.role)).toEqual(['fan-out']);
+ expect(pipe.slots.get('bob')?.map((slot) => slot.role)).toEqual(['fan-out', 'final']);
+ });
+});
+
+// ── Lease management ──────────────────────────────────────────────────────────
+
+describe('pipe-store lease management', () => {
+ it('grants a lease to an assignee', () => {
+ createLinearPipe();
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.lease?.assignee).toBe('alice');
+ expect(result.lease?.pipeId).toBe('pipe-1');
+
+ const lease = pipeStore.getActiveLease('alice', 'proj-1');
+ expect(lease?.pipeId).toBe('pipe-1');
+ });
+
+ it('rejects lease for a different pipe when one is already held', () => {
+ createLinearPipe();
+ pipeStore.createPipe('pipe-other', 'linear', ['alice', 'bob'], 'other', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+
+ const result = pipeStore.grantLease('pipe-other', 'alice', 'proj-1');
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('already holds a lease');
+ });
+
+ it('allows re-granting for the same pipe (idempotent)', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(result.ok).toBe(true);
+ });
+
+ it('rejects lease for non-assignee', () => {
+ createLinearPipe();
+ const result = pipeStore.grantLease('pipe-1', 'stranger', 'proj-1');
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('not an assignee');
+ });
+
+ it('rejects lease for already-submitted slot', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true);
+
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('has no pending tasks');
+ });
+
+ it('releases a lease', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.releaseLease('alice', 'proj-1');
+ expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined();
+ });
+});
+
+// ── Stage submission ──────────────────────────────────────────────────────────
+
+describe('pipe-store submitStage', () => {
+ it('accepts submission with valid lease', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const result = pipeStore.submitStage('pipe-1', 'alice', 'my output', 'proj-1', true);
+ expect(result.ok).toBe(true);
+ expect(result.slot?.status).toBe('submitted');
+ expect(result.slot?.content).toBe('my output');
+ // Lease should be released after submission
+ expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined();
+ });
+
+ it('rejects submission without lease when requireLease=true', () => {
+ createLinearPipe();
+ // Don't grant a lease
+ const result = pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', true);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('does not hold a lease');
+ });
+
+ it('rejects double submission', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.submitStage('pipe-1', 'alice', 'first', 'proj-1', true);
+
+ const result = pipeStore.submitStage('pipe-1', 'alice', 'second', 'proj-1', false);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('already submitted');
+ });
+
+ it('rejects submission for non-running pipe', () => {
+ createLinearPipe();
+ pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1');
+ const result = pipeStore.submitStage('pipe-1', 'alice', 'output', 'proj-1', false);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('cancelled');
+ });
+
+ it('rejects submission from non-assignee', () => {
+ createLinearPipe();
+ const result = pipeStore.submitStage('pipe-1', 'stranger', 'output', 'proj-1', false);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('not an assignee');
+ });
+
+ it('merge-all synthesizer submits fan-out before final', () => {
+ pipeStore.createPipe('pipe-3', 'merge-all', ['alice', 'bob'], 'test', 'proj-1');
+
+ const firstLease = pipeStore.grantLease('pipe-3', 'bob', 'proj-1');
+ expect(firstLease.ok).toBe(true);
+ expect(firstLease.lease?.slotRole).toBe('fan-out');
+
+ const firstSubmit = pipeStore.submitStage('pipe-3', 'bob', 'blind analysis', 'proj-1', true);
+ expect(firstSubmit.ok).toBe(true);
+ expect(firstSubmit.slot?.role).toBe('fan-out');
+
+ const secondLease = pipeStore.grantLease('pipe-3', 'bob', 'proj-1');
+ expect(secondLease.ok).toBe(true);
+ expect(secondLease.lease?.slotRole).toBe('final');
+
+ const secondSubmit = pipeStore.submitStage('pipe-3', 'bob', 'merged answer', 'proj-1', true);
+ expect(secondSubmit.ok).toBe(true);
+ expect(secondSubmit.slot?.role).toBe('final');
+ });
+});
+
+// ── Queries ───────────────────────────────────────────────────────────────────
+
+describe('pipe-store queries', () => {
+ it('getStageOutput returns submitted content by stage number', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.submitStage('pipe-1', 'alice', 'stage 1 output', 'proj-1', true);
+
+ const output = pipeStore.getStageOutput('pipe-1', 1, 'proj-1');
+ expect(output).toEqual({ from: 'alice', body: 'stage 1 output' });
+
+ // Stage 2 not yet submitted
+ expect(pipeStore.getStageOutput('pipe-1', 2, 'proj-1')).toBeUndefined();
+ });
+
+ it('getFanOutOutputs returns all submitted fan-out content', () => {
+ createMergePipe();
+ pipeStore.grantLease('pipe-2', 'alice', 'proj-1');
+ pipeStore.submitStage('pipe-2', 'alice', 'alice output', 'proj-1', true);
+ pipeStore.grantLease('pipe-2', 'bob', 'proj-1');
+ pipeStore.submitStage('pipe-2', 'bob', 'bob output', 'proj-1', true);
+
+ const outputs = pipeStore.getFanOutOutputs('pipe-2', 'proj-1');
+ expect(outputs.size).toBe(2);
+ expect(outputs.get('alice')).toBe('alice output');
+ expect(outputs.get('bob')).toBe('bob output');
+ });
+
+ it('getPipeStatus returns full pipe summary', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+
+ const status = pipeStore.getPipeStatus('pipe-1', 'proj-1');
+ expect(status).toBeDefined();
+ expect(status!.pipeId).toBe('pipe-1');
+ expect(status!.slots).toHaveLength(3);
+ expect(status!.leases).toHaveLength(1);
+ expect(status!.leases[0].assignee).toBe('alice');
+ });
+});
+
+// ── Pending pipe queue ────────────────────────────────────────────────────────
+
+describe('pipe-store pending pipe queue', () => {
+ it('tracks pending pipes for lease conflicts', () => {
+ createLinearPipe();
+ pipeStore.createPipe('pipe-other', 'linear', ['alice', 'bob'], 'other', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+
+ // pipe-other can't get a lease for alice — add to pending
+ pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-other');
+
+ // Pop returns the pending pipe
+ const pending = pipeStore.popPendingPipes('alice', 'proj-1');
+ expect(pending).toEqual(['pipe-other']);
+
+ // Second pop returns empty
+ expect(pipeStore.popPendingPipes('alice', 'proj-1')).toEqual([]);
+ });
+
+ it('returns empty array when no pending pipes', () => {
+ expect(pipeStore.popPendingPipes('nobody', 'proj-1')).toEqual([]);
+ });
+});
+
+// ── Lifecycle ─────────────────────────────────────────────────────────────────
+
+describe('pipe-store lifecycle', () => {
+ it('markPipeStatus returns released assignees so callers can drain pending queues', () => {
+ createLinearPipe();
+ pipeStore.createPipe('pipe-blocked', 'linear', ['alice', 'bob'], 'blocked', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+
+ // pipe-blocked is queued behind alice's lease on pipe-1
+ pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-blocked');
+
+ // Cancel pipe-1 — alice's lease is released
+ const released = pipeStore.markPipeStatus('pipe-1', 'cancelled', 'proj-1');
+ expect(released).toContain('alice');
+ expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined();
+
+ // Caller can now drain pending pipes for the released assignees
+ const pending = pipeStore.popPendingPipes('alice', 'proj-1');
+ expect(pending).toEqual(['pipe-blocked']);
+
+ // pipe-blocked can now get alice's lease
+ const leaseResult = pipeStore.grantLease('pipe-blocked', 'alice', 'proj-1');
+ expect(leaseResult.ok).toBe(true);
+ });
+
+ it('markPipeStatus on failure also enables pending drain', () => {
+ createLinearPipe();
+ pipeStore.createPipe('pipe-waiting', 'linear', ['alice', 'bob'], 'waiting', 'proj-1');
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ pipeStore.addPendingPipe('alice', 'proj-1', 'pipe-waiting');
+
+ const released = pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1');
+ expect(released).toContain('alice');
+
+ const pending = pipeStore.popPendingPipes('alice', 'proj-1');
+ expect(pending).toEqual(['pipe-waiting']);
+ });
+
+ it('markPipeStatus releases all leases on terminal status', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeDefined();
+
+ pipeStore.markPipeStatus('pipe-1', 'failed', 'proj-1');
+ expect(pipeStore.getActiveLease('alice', 'proj-1')).toBeUndefined();
+
+ const pipe = pipeStore.getPipe('pipe-1', 'proj-1');
+ expect(pipe?.status).toBe('failed');
+ });
+
+ it('project isolation: pipes in different projects are independent', () => {
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt', 'proj-1');
+ pipeStore.createPipe('pipe-1', 'linear', ['alice', 'bob'], 'prompt', 'proj-2');
+
+ // Grant lease in proj-1
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+
+ // Can still grant in proj-2 (different project, no conflict)
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-2');
+ expect(result.ok).toBe(true);
+ });
+});
+
+// ── Lease-aware authorization (claude-15) ────────────────────────────────────
+
+describe('pipe-store lease expiry enforcement', () => {
+ it('isLeaseExpired returns false when no deadline', () => {
+ pipeStore.createPipe('pipe-no-timeout', 'linear', ['alice', 'bob'], 'test', 'proj-1', { stageTimeoutMs: 0 });
+ const result = pipeStore.grantLease('pipe-no-timeout', 'alice', 'proj-1');
+ expect(result.ok).toBe(true);
+ expect(result.lease!.deadline).toBeNull();
+ expect(pipeStore.isLeaseExpired(result.lease!)).toBe(false);
+ });
+
+ it('isLeaseExpired returns true when past deadline', () => {
+ createLinearPipe();
+ const result = pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const farFuture = Date.now() + 10 * 60 * 1000;
+ expect(pipeStore.isLeaseExpired(result.lease!, farFuture)).toBe(true);
+ });
+
+ it('submitStage accepts submission with active lease', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const result = pipeStore.submitStage('pipe-1', 'alice', 'timely output', 'proj-1', true);
+ expect(result.ok).toBe(true);
+ });
+});
+
+describe('pipe-store assignment queries', () => {
+ it('getAssignmentsForParticipant returns slots', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const a = assignmentQueries.getAssignmentsForParticipant('alice', 'proj-1');
+ expect(a).toHaveLength(1);
+ expect(a[0].leaseStatus).toBe('active');
+ });
+
+ it('getAssignmentForPipe returns details', () => {
+ createLinearPipe();
+ pipeStore.grantLease('pipe-1', 'alice', 'proj-1');
+ const a = assignmentQueries.getAssignmentForPipe('pipe-1', 'alice', 'proj-1');
+ expect(a).toBeDefined();
+ expect(a!.leaseStatus).toBe('active');
+ });
+
+ it('getAssignmentForPipe returns undefined for non-assignee', () => {
+ createLinearPipe();
+ expect(assignmentQueries.getAssignmentForPipe('pipe-1', 'stranger', 'proj-1')).toBeUndefined();
+ });
+});
diff --git a/src/apps/chat/services/pipe-store.ts b/src/apps/chat/services/pipe-store.ts
new file mode 100644
index 0000000..f9ea3ca
--- /dev/null
+++ b/src/apps/chat/services/pipe-store.ts
@@ -0,0 +1,995 @@
+import { createHash } from 'crypto';
+import type { PipeMode, PipeStatus, PipeTimeoutPolicy, StageTiming, PipeTimingSummary, DeadLetterEntry, RuntimeLeaseStatus } from '../types.js';
+import { systemClock, type Clock } from './clock.js';
+import { getExhaustedDeliveries } from './pipe-delivery.js';
+
+let _pipeClock: Clock = systemClock;
+export function _setObsClockForTest(c: Clock): void { _pipeClock = c; }
+
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface PipeSlot {
+ assignee: string;
+ role: 'stage-output' | 'fan-out' | 'final';
+ stage?: number; // 1-indexed, for linear pipes
+ status: 'pending' | 'leased' | 'submitted';
+ content: string | null;
+ submittedAt: string | null;
+}
+
+export interface StoredPipe {
+ pipeId: string;
+ mode: PipeMode;
+ assignees: string[];
+ prompt: string;
+ status: PipeStatus;
+ slots: Map; // keyed by assignee name
+ createdAt: string;
+ // Emission tracking — replaces log scanning for reducer idempotency
+ emittedHandoffs: Set; // linear stage numbers that have been delivered
+ emittedFanOutRequests: Set; // assignee names that received fan-out requests
+ emittedSynthRequest: boolean; // whether synth-request has been sent
+ // Stage timeout configuration
+ stageTimeoutMs: number; // per-stage deadline in milliseconds (0 = no timeout)
+ timeoutPolicy: PipeTimeoutPolicy; // what to do when a stage times out
+}
+
+export interface LeaseInfo {
+ pipeId: string;
+ assignee: string;
+ slotRole: string;
+ stage?: number;
+ grantedAt: string;
+ deadline: string | null; // ISO timestamp when this lease expires (null = no deadline)
+}
+
+export type PipeErrorCode =
+ | 'PIPE_NOT_FOUND'
+ | 'PIPE_CLOSED'
+ | 'PIPE_NOT_ASSIGNED'
+ | 'PIPE_LEASE_NOT_HELD'
+ | 'PIPE_LEASE_EXPIRED'
+ | 'PIPE_ALREADY_SUBMITTED'
+ | 'PIPE_LEASE_CONFLICT';
+
+export interface SubmitResult {
+ ok: boolean;
+ error?: string;
+ code?: PipeErrorCode;
+ slot?: PipeSlot;
+ pipe?: { pipeId: string; mode: PipeMode; status: PipeStatus };
+}
+
+export interface StageInputSource {
+ from: string;
+ content: string;
+}
+
+export interface StageInputPayload {
+ role: 'prompt' | 'upstream-output' | 'fan-out-prompt' | 'fan-out-outputs';
+ content: string | null;
+ contentHash: string | null;
+ contentVersion: number;
+ stage?: number;
+ totalStages?: number;
+ assignee: string;
+ prompt: string;
+ sources?: StageInputSource[];
+}
+
+export type StageInputResult =
+ | { ok: true; input: StageInputPayload }
+ | { ok: false; code: PipeErrorCode; error: string };
+
+// ── Storage ───────────────────────────────────────────────────────────────────
+
+// projectId -> (pipeId -> StoredPipe)
+const stores = new Map>();
+
+// "projectId:assigneeName" -> LeaseInfo (one active lease per participant)
+const activeLeases = new Map();
+
+// "projectId:assigneeName" -> Set (pipes waiting for this participant's lease to release)
+const pendingPipes = new Map>();
+
+// "projectId:assigneeName" -> Set (index of active/running pipes per participant)
+const activePipeIndex = new Map>();
+
+function addToActivePipeIndex(assignee: string, projectId: string | null, pipeId: string): void {
+ const key = leaseKey(assignee, projectId);
+ let pipeIds = activePipeIndex.get(key);
+ if (!pipeIds) { pipeIds = new Set(); activePipeIndex.set(key, pipeIds); }
+ pipeIds.add(pipeId);
+}
+
+function removeFromActivePipeIndex(assignee: string, projectId: string | null, pipeId: string): void {
+ const key = leaseKey(assignee, projectId);
+ const pipeIds = activePipeIndex.get(key);
+ if (pipeIds) {
+ pipeIds.delete(pipeId);
+ if (pipeIds.size === 0) activePipeIndex.delete(key);
+ }
+}
+
+/** Get all running pipe IDs for a participant (O(1) lookup). */
+export function getActivePipesForParticipant(assignee: string, projectId: string | null): string[] {
+ const key = leaseKey(assignee, projectId);
+ const pipeIds = activePipeIndex.get(key);
+ return pipeIds ? [...pipeIds] : [];
+}
+
+function leaseKey(assignee: string, projectId: string | null): string {
+ return `${projectId ?? '__none__'}:${assignee}`;
+}
+
+/** Get all projectIds that have pipe data in the store. */
+export function getTrackedProjectIds(): Array {
+ return [...stores.keys()];
+}
+
+function getProjectStore(projectId: string | null): Map {
+ let store = stores.get(projectId);
+ if (!store) {
+ store = new Map();
+ stores.set(projectId, store);
+ }
+ return store;
+}
+
+function computeContentHash(content: string): string {
+ return createHash('sha256').update(content, 'utf8').digest('hex');
+}
+
+// ── Pipe lifecycle ────────────────────────────────────────────────────────────
+
+export const DEFAULT_STAGE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
+
+/** Create a new pipe in the store with slots for each assignee. */
+export function createPipe(
+ pipeId: string,
+ mode: PipeMode,
+ assignees: string[],
+ prompt: string,
+ projectId: string | null,
+ opts?: { stageTimeoutMs?: number; timeoutPolicy?: PipeTimeoutPolicy },
+): StoredPipe {
+ const store = getProjectStore(projectId);
+ const slots = new Map();
+
+ if (mode === 'linear') {
+ for (let i = 0; i < assignees.length; i++) {
+ const isLast = i === assignees.length - 1;
+ slots.set(assignees[i], [{
+ assignee: assignees[i],
+ role: isLast ? 'final' : 'stage-output',
+ stage: i + 1,
+ status: 'pending',
+ content: null,
+ submittedAt: null,
+ }]);
+ }
+ } else if (mode === 'merge') {
+ // merge: fan-out assignees + last one is synthesizer
+ const fanOutAssignees = assignees.slice(0, -1);
+ const synthesizer = assignees[assignees.length - 1];
+ for (const a of fanOutAssignees) {
+ slots.set(a, [{
+ assignee: a,
+ role: 'fan-out',
+ status: 'pending',
+ content: null,
+ submittedAt: null,
+ }]);
+ }
+ slots.set(synthesizer, [{
+ assignee: synthesizer,
+ role: 'final',
+ status: 'pending',
+ content: null,
+ submittedAt: null,
+ }]);
+ } else {
+ // merge-all style: ALL assignees get fan-out + last one gets final
+ for (let i = 0; i < assignees.length; i++) {
+ const a = assignees[i];
+ const isLast = i === assignees.length - 1;
+ const assigneeSlots: PipeSlot[] = [{
+ assignee: a,
+ role: 'fan-out',
+ status: 'pending',
+ content: null,
+ submittedAt: null,
+ }];
+ if (isLast) {
+ assigneeSlots.push({
+ assignee: a,
+ role: 'final',
+ status: 'pending',
+ content: null,
+ submittedAt: null,
+ });
+ }
+ slots.set(a, assigneeSlots);
+ }
+ }
+
+ const pipe: StoredPipe = {
+ pipeId,
+ mode,
+ assignees,
+ prompt,
+ status: 'running',
+ slots,
+ createdAt: new Date().toISOString(),
+ emittedHandoffs: new Set(),
+ emittedFanOutRequests: new Set(),
+ emittedSynthRequest: false,
+ stageTimeoutMs: opts?.stageTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS,
+ timeoutPolicy: opts?.timeoutPolicy ?? 'fail',
+ };
+ store.set(pipeId, pipe);
+
+ // Populate active pipe index for all assignees
+ for (const a of assignees) {
+ addToActivePipeIndex(a, projectId, pipeId);
+ }
+
+ return pipe;
+}
+
+/** Get a pipe from the store. */
+export function getPipe(pipeId: string, projectId: string | null): StoredPipe | undefined {
+ return getProjectStore(projectId).get(pipeId);
+}
+
+/** Track that a reducer action has been emitted (for idempotency without log scanning). */
+export function markEmitted(
+ pipeId: string,
+ type: 'handoff' | 'fan-out-request' | 'synth-request',
+ key: string | number | undefined,
+ projectId: string | null,
+): void {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return;
+ if (type === 'handoff' && typeof key === 'number') pipe.emittedHandoffs.add(key);
+ else if (type === 'fan-out-request' && typeof key === 'string') pipe.emittedFanOutRequests.add(key);
+ else if (type === 'synth-request') pipe.emittedSynthRequest = true;
+}
+
+/** Mark a pipe as completed, failed, or cancelled. Releases all leases for its assignees.
+ * Returns assignee names whose leases were released (callers should drain their pending queues). */
+export function markPipeStatus(pipeId: string, status: PipeStatus, projectId: string | null): string[] {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return [];
+ pipe.status = status;
+ if (status !== 'running') {
+ // Remove from active pipe index for all assignees
+ for (const a of pipe.assignees) {
+ removeFromActivePipeIndex(a, projectId, pipeId);
+ }
+ return releaseAllLeases(pipe, projectId);
+ }
+ return [];
+}
+
+// ── Lease management ──────────────────────────────────────────────────────────
+
+/** Grant a lease to a participant for a specific pipe.
+ * A participant can only hold one active lease at a time.
+ * Returns error if the participant already holds a lease for a *different* pipe. */
+export function grantLease(
+ pipeId: string,
+ assignee: string,
+ projectId: string | null,
+): { ok: boolean; error?: string; code?: PipeErrorCode; lease?: LeaseInfo } {
+ const key = leaseKey(assignee, projectId);
+ const existing = activeLeases.get(key);
+
+ // Allow re-granting for the same pipe (idempotent)
+ if (existing && existing.pipeId !== pipeId) {
+ return {
+ ok: false,
+ code: 'PIPE_LEASE_CONFLICT',
+ error: `${assignee} already holds a lease for pipe #${existing.pipeId}. ` +
+ `Complete or release that pipe before starting pipe #${pipeId}.`,
+ };
+ }
+
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` };
+ if (pipe.status !== 'running') return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` };
+
+ const assigneeSlots = pipe.slots.get(assignee);
+ if (!assigneeSlots || assigneeSlots.length === 0) {
+ return { ok: false, error: `${assignee} is not an assignee of pipe #${pipeId}` };
+ }
+
+ // Find the first pending or leased task for this assignee.
+ // In merge-all, this will be fan-out first, then final.
+ const slot = assigneeSlots.find(s => s.status === 'pending' || s.status === 'leased');
+ if (!slot) return { ok: false, error: `${assignee} has no pending tasks for pipe #${pipeId}` };
+
+ slot.status = 'leased';
+ const now = new Date();
+ const deadline = pipe.stageTimeoutMs > 0
+ ? new Date(now.getTime() + pipe.stageTimeoutMs).toISOString()
+ : null;
+ const lease: LeaseInfo = {
+ pipeId,
+ assignee,
+ slotRole: slot.role,
+ stage: slot.stage,
+ grantedAt: now.toISOString(),
+ deadline,
+ };
+ activeLeases.set(key, lease);
+ return { ok: true, lease };
+}
+
+/** Release a participant's active lease. */
+export function releaseLease(assignee: string, projectId: string | null): void {
+ activeLeases.delete(leaseKey(assignee, projectId));
+}
+
+/** Get the active lease for a participant (if any). */
+export function getActiveLease(assignee: string, projectId: string | null): LeaseInfo | undefined {
+ return activeLeases.get(leaseKey(assignee, projectId));
+}
+
+/** Check whether a lease has passed its deadline. */
+export function isLeaseExpired(lease: LeaseInfo, now: number = Date.now()): boolean {
+ if (!lease.deadline) return false;
+ return now >= new Date(lease.deadline).getTime();
+}
+
+/** Release all leases for a pipe's assignees. Returns names of assignees whose leases were released. */
+function releaseAllLeases(pipe: StoredPipe, projectId: string | null): string[] {
+ const released: string[] = [];
+ for (const assignee of pipe.assignees) {
+ const lease = getActiveLease(assignee, projectId);
+ if (lease?.pipeId === pipe.pipeId) {
+ releaseLease(assignee, projectId);
+ released.push(assignee);
+ }
+ }
+ return released;
+}
+
+/** Get all active leases (for watchdog / deadline checks). */
+export function getAllActiveLeases(): ReadonlyMap {
+ return activeLeases;
+}
+
+// ── Pending pipe queue (for lease conflicts) ──────────────────────────────────
+
+/** Record that a pipe is waiting for a participant's lease to be released.
+ * Pipes are drained in creation-time order (oldest first). */
+export function addPendingPipe(assignee: string, projectId: string | null, pipeId: string): void {
+ const key = leaseKey(assignee, projectId);
+ let pending = pendingPipes.get(key);
+ if (!pending) { pending = new Set(); pendingPipes.set(key, pending); }
+ pending.add(pipeId);
+}
+
+/** Pop all pending pipe IDs for a participant (called after lease release).
+ * Returns pipe IDs sorted by pipe creation time (oldest first). */
+export function popPendingPipes(assignee: string, projectId: string | null): string[] {
+ const key = leaseKey(assignee, projectId);
+ const pending = pendingPipes.get(key);
+ if (!pending || pending.size === 0) return [];
+ const result = [...pending];
+ pendingPipes.delete(key);
+
+ // Sort by pipe creation time so older pipes are drained first
+ const store = getProjectStore(projectId);
+ result.sort((a, b) => {
+ const pipeA = store.get(a);
+ const pipeB = store.get(b);
+ if (!pipeA || !pipeB) return 0;
+ return pipeA.createdAt.localeCompare(pipeB.createdAt);
+ });
+
+ return result;
+}
+
+// ── Stage submission ──────────────────────────────────────────────────────────
+
+/** Submit stage output for a pipe.
+ * @param requireLease If true (default), the assignee must hold the lease. Set false for backward compat via chat_send. */
+export function submitStage(
+ pipeId: string,
+ assignee: string,
+ content: string,
+ projectId: string | null,
+ requireLease = true,
+): SubmitResult {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` };
+ if (pipe.status !== 'running') return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` };
+
+ const assigneeSlots = pipe.slots.get(assignee);
+ if (!assigneeSlots || assigneeSlots.length === 0) {
+ return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not an assignee of pipe #${pipeId}` };
+ }
+
+ let slot: PipeSlot | undefined;
+ if (requireLease) {
+ const lease = getActiveLease(assignee, projectId);
+ if (!lease || lease.pipeId !== pipeId) {
+ return {
+ ok: false,
+ code: 'PIPE_LEASE_NOT_HELD',
+ error: `${assignee} does not hold a lease for pipe #${pipeId}. ` +
+ `Stage submission requires an active lease granted by the system.`,
+ };
+ }
+ // Reject submits after the lease deadline has passed.
+ if (isLeaseExpired(lease)) {
+ return {
+ ok: false,
+ code: 'PIPE_LEASE_EXPIRED',
+ error: `Lease for ${assignee} on pipe #${pipeId} expired at ${lease.deadline}. ` +
+ `The stage deadline has passed — submission rejected.`,
+ };
+ }
+ slot = assigneeSlots.find(s => s.role === lease.slotRole && (s.stage === lease.stage || (s.stage === undefined && lease.stage === undefined)) && s.status === 'leased');
+ } else {
+ // Non-leased submission (backward compat) — take the first non-submitted task
+ slot = assigneeSlots.find(s => s.status !== 'submitted');
+ }
+
+ if (!slot) return { ok: false, code: 'PIPE_ALREADY_SUBMITTED', error: `${assignee} already submitted all tasks for pipe #${pipeId}` };
+
+ slot.content = content;
+ slot.status = 'submitted';
+ slot.submittedAt = new Date().toISOString();
+ releaseLease(assignee, projectId);
+
+ return {
+ ok: true,
+ slot: { ...slot },
+ pipe: { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status },
+ };
+}
+
+// ── Assignment queries (reconnect / recovery) ───────────────────────────────
+
+export interface ParticipantAssignment {
+ pipeId: string;
+ mode: PipeMode;
+ role: PipeSlot['role'];
+ stage?: number;
+ slotStatus: PipeSlot['status'];
+ leaseStatus: 'active' | 'expired' | 'none';
+ deadline: string | null;
+ grantedAt: string | null;
+ pipeStatus: PipeStatus;
+}
+
+/** List all non-submitted slots for a participant across running pipes.
+ * Used by reconnect recovery and the pipe_list_assignments tool. */
+export function getAssignmentsForParticipant(
+ assignee: string,
+ projectId: string | null,
+): ParticipantAssignment[] {
+ const activePipeIds = getActivePipesForParticipant(assignee, projectId);
+ const assignments: ParticipantAssignment[] = [];
+ const lease = getActiveLease(assignee, projectId);
+
+ for (const pipeId of activePipeIds) {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) continue;
+
+ const slots = pipe.slots.get(assignee);
+ if (!slots) continue;
+
+ for (const slot of slots) {
+ if (slot.status === 'submitted') continue;
+
+ const isLeasedSlot = lease?.pipeId === pipeId
+ && lease.slotRole === slot.role
+ && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined));
+
+ let leaseStatus: ParticipantAssignment['leaseStatus'] = 'none';
+ let deadline: string | null = null;
+ let grantedAt: string | null = null;
+
+ if (isLeasedSlot && lease) {
+ leaseStatus = isLeaseExpired(lease) ? 'expired' : 'active';
+ deadline = lease.deadline;
+ grantedAt = lease.grantedAt;
+ }
+
+ assignments.push({
+ pipeId,
+ mode: pipe.mode,
+ role: slot.role,
+ stage: slot.stage,
+ slotStatus: slot.status,
+ leaseStatus,
+ deadline,
+ grantedAt,
+ pipeStatus: pipe.status,
+ });
+ }
+ }
+
+ return assignments;
+}
+
+// ── Queries ───────────────────────────────────────────────────────────────────
+
+/** Get the stored output for a linear stage. */
+export function getStageOutput(
+ pipeId: string,
+ stage: number,
+ projectId: string | null,
+): { from: string; body: string } | undefined {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return undefined;
+ for (const slotList of pipe.slots.values()) {
+ for (const slot of slotList) {
+ if (slot.stage === stage && slot.status === 'submitted' && slot.content) {
+ return { from: slot.assignee, body: slot.content };
+ }
+ }
+ }
+ return undefined;
+}
+
+/** Get all submitted fan-out outputs for a merge pipe. */
+export function getFanOutOutputs(
+ pipeId: string,
+ projectId: string | null,
+): Map {
+ const pipe = getPipe(pipeId, projectId);
+ const outputs = new Map();
+ if (!pipe) return outputs;
+ for (const slotList of pipe.slots.values()) {
+ for (const slot of slotList) {
+ if (slot.role === 'fan-out' && slot.status === 'submitted' && slot.content) {
+ outputs.set(slot.assignee, slot.content);
+ }
+ }
+ }
+ return outputs;
+}
+
+export function computeStageInput(
+ pipeId: string,
+ stage: number | undefined,
+ assignee: string,
+ projectId: string | null,
+): StageInputResult {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) {
+ return { ok: false, code: 'PIPE_NOT_FOUND', error: `Pipe #${pipeId} not found` };
+ }
+ if (pipe.status !== 'running') {
+ return { ok: false, code: 'PIPE_CLOSED', error: `Pipe #${pipeId} is ${pipe.status}` };
+ }
+
+ const assigneeIndex = pipe.assignees.indexOf(assignee);
+ if (assigneeIndex === -1) {
+ return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not an assignee of pipe #${pipeId}` };
+ }
+
+ const resolvedStage = stage ?? (pipe.mode === 'linear' ? assigneeIndex + 1 : undefined);
+
+ if (pipe.mode === 'linear') {
+ if (!resolvedStage || resolvedStage < 1 || resolvedStage > pipe.assignees.length) {
+ return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `Invalid stage ${String(stage)} for pipe #${pipeId}` };
+ }
+ if (pipe.assignees[resolvedStage - 1] !== assignee) {
+ return { ok: false, code: 'PIPE_NOT_ASSIGNED', error: `${assignee} is not assigned to stage ${resolvedStage} of pipe #${pipeId}` };
+ }
+ if (resolvedStage === 1) {
+ return {
+ ok: true,
+ input: {
+ role: 'prompt',
+ content: pipe.prompt,
+ contentHash: computeContentHash(pipe.prompt),
+ contentVersion: 1,
+ stage: 1,
+ totalStages: pipe.assignees.length,
+ assignee,
+ prompt: pipe.prompt,
+ },
+ };
+ }
+
+ const previous = getStageOutput(pipeId, resolvedStage - 1, projectId);
+ return {
+ ok: true,
+ input: {
+ role: 'upstream-output',
+ content: previous?.body ?? null,
+ contentHash: previous ? computeContentHash(previous.body) : null,
+ contentVersion: previous ? 1 : 0,
+ stage: resolvedStage,
+ totalStages: pipe.assignees.length,
+ assignee,
+ prompt: pipe.prompt,
+ sources: previous ? [{ from: previous.from, content: previous.body }] : [],
+ },
+ };
+ }
+
+ const isMergeAll = pipe.mode === 'merge-all' || pipe.mode === 'explain' || pipe.mode === 'summarize';
+ const synthesizer = pipe.assignees[pipe.assignees.length - 1];
+ const callerSlots = pipe.slots.get(assignee) ?? [];
+ const callerFanOutSlot = callerSlots.find(slot => slot.role === 'fan-out') ?? null;
+ const callerFinalSlot = callerSlots.find(slot => slot.role === 'final') ?? null;
+ const fanOutOutputs = [...getFanOutOutputs(pipeId, projectId).entries()]
+ .filter(([from]) => !(isMergeAll && from === synthesizer))
+ .map(([from, content]) => ({ from, content }));
+
+ // Dual-role synthesizers stay in fan-out mode until their own fan-out slot is submitted.
+ const isSynthPhase = assignee === synthesizer
+ && callerFinalSlot != null
+ && (!callerFanOutSlot || callerFanOutSlot.status === 'submitted');
+ if (!isSynthPhase) {
+ return {
+ ok: true,
+ input: {
+ role: 'fan-out-prompt',
+ content: pipe.prompt,
+ contentHash: computeContentHash(pipe.prompt),
+ contentVersion: 1,
+ assignee,
+ prompt: pipe.prompt,
+ sources: [],
+ },
+ };
+ }
+
+ const mergedContent = fanOutOutputs.map(({ from, content }) => `${from}: ${content}`).join('\n\n');
+ return {
+ ok: true,
+ input: {
+ role: 'fan-out-outputs',
+ content: fanOutOutputs.length > 0 ? mergedContent : null,
+ contentHash: fanOutOutputs.length > 0 ? computeContentHash(mergedContent) : null,
+ contentVersion: fanOutOutputs.length > 0 ? 1 : 0,
+ assignee,
+ prompt: pipe.prompt,
+ sources: fanOutOutputs,
+ },
+ };
+}
+
+/** Get pipe status summary for the pipe_status tool. */
+export function getPipeStatus(pipeId: string, projectId: string | null): {
+ pipeId: string;
+ mode: PipeMode;
+ status: PipeStatus;
+ assignees: string[];
+ prompt: string;
+ slots: Array<{
+ assignee: string;
+ role: string;
+ stage?: number;
+ status: string;
+ hasContent: boolean;
+ submittedAt: string | null;
+ }>;
+ leases: Array;
+} | undefined {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return undefined;
+
+ const slots: Array<{
+ assignee: string;
+ role: string;
+ stage?: number;
+ status: string;
+ hasContent: boolean;
+ submittedAt: string | null;
+ }> = [];
+ const leases: LeaseInfo[] = [];
+
+ for (const [, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ slots.push({
+ assignee: slot.assignee,
+ role: slot.role,
+ stage: slot.stage,
+ status: slot.status,
+ hasContent: slot.content !== null,
+ submittedAt: slot.submittedAt,
+ });
+ }
+ const lease = getActiveLease(slotList[0].assignee, projectId);
+ if (lease?.pipeId === pipeId) leases.push(lease);
+ }
+
+ return {
+ pipeId: pipe.pipeId,
+ mode: pipe.mode,
+ status: pipe.status,
+ assignees: pipe.assignees,
+ prompt: pipe.prompt,
+ slots,
+ leases,
+ };
+}
+
+/** List all active (running) pipes for a project. */
+export function listActivePipes(projectId: string | null): Array<{
+ pipeId: string;
+ mode: PipeMode;
+ status: PipeStatus;
+ assignees: string[];
+}> {
+ const store = getProjectStore(projectId);
+ const result: Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[] }> = [];
+ for (const pipe of store.values()) {
+ if (pipe.status === 'running') {
+ result.push({ pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, assignees: pipe.assignees });
+ }
+ }
+ return result;
+}
+
+// ── Terminal pipe cleanup ────────────────────────────────────────────────────
+
+export const DEFAULT_PIPE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+/** Remove terminal pipes (completed/failed/cancelled) that exceed the given TTL.
+ * Returns the pipeIds that were removed. */
+export function cleanupTerminalPipes(
+ projectId: string | null,
+ ttlMs: number = DEFAULT_PIPE_TTL_MS,
+): string[] {
+ const store = getProjectStore(projectId);
+ const now = Date.now();
+ const removed: string[] = [];
+
+ for (const [pipeId, pipe] of store) {
+ if (pipe.status === 'running') continue;
+ // Use the last slot submission time or createdAt as the reference
+ let latestTs = new Date(pipe.createdAt).getTime();
+ for (const [, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.submittedAt) {
+ const t = new Date(slot.submittedAt).getTime();
+ if (t > latestTs) latestTs = t;
+ }
+ }
+ }
+ if (now - latestTs >= ttlMs) {
+ store.delete(pipeId);
+ removed.push(pipeId);
+ }
+ }
+
+ return removed;
+}
+
+// ── Recovery from persisted events ───────────────────────────────────────────
+
+export interface PipeRecoveryEvent {
+ type: string;
+ pipeId: string;
+ mode?: PipeMode;
+ assignees?: string[];
+ prompt?: string;
+ stageTimeoutMs?: number;
+ timeoutPolicy?: PipeTimeoutPolicy;
+ from?: string;
+ role?: string;
+ stage?: number;
+ content?: string;
+}
+
+/** Rehydrate pipe state from persisted events.
+ * Called on server restart to rebuild in-memory pipes from event logs.
+ * Returns the list of pipeIds that are still in 'running' state. */
+export function rehydrateFromEvents(
+ events: PipeRecoveryEvent[],
+ projectId: string | null,
+): string[] {
+ // Group events by pipeId, preserving order
+ const grouped = new Map();
+ for (const event of events) {
+ let list = grouped.get(event.pipeId);
+ if (!list) { list = []; grouped.set(event.pipeId, list); }
+ list.push(event);
+ }
+
+ const runningPipes: string[] = [];
+
+ for (const [pipeId, pipeEvents] of grouped) {
+ // Find the start event
+ const startEvent = pipeEvents.find(e => e.type === 'start');
+ if (!startEvent || !startEvent.assignees || !startEvent.prompt || !startEvent.mode) continue;
+
+ // Skip if already in store (shouldn't happen, but defensive)
+ if (getPipe(pipeId, projectId)) continue;
+
+ // Recreate the pipe
+ createPipe(pipeId, startEvent.mode, startEvent.assignees, startEvent.prompt, projectId, {
+ stageTimeoutMs: startEvent.stageTimeoutMs,
+ timeoutPolicy: startEvent.timeoutPolicy,
+ });
+
+ // Replay submissions
+ for (const event of pipeEvents) {
+ if (event.type === 'stage-output' && event.from && event.content) {
+ submitStage(pipeId, event.from, event.content, projectId, false);
+ }
+ }
+
+ // Apply terminal status if pipe ended
+ const terminalEvent = pipeEvents.find(e =>
+ e.type === 'complete' || e.type === 'failed' || e.type === 'cancel'
+ );
+ if (terminalEvent) {
+ const status: PipeStatus =
+ terminalEvent.type === 'complete' ? 'completed' :
+ terminalEvent.type === 'cancel' ? 'cancelled' : 'failed';
+ markPipeStatus(pipeId, status, projectId);
+ } else {
+ // Pipe was running when server stopped — it's recoverable.
+ // Rebuild emission tracking from submitted slot state so the reducer
+ // doesn't re-emit handoffs/fan-outs that were already delivered.
+ rebuildEmissionState(pipeId, projectId);
+ runningPipes.push(pipeId);
+ }
+ }
+
+ return runningPipes;
+}
+
+/** Rebuild emission tracking from submitted slot state.
+ * After rehydration, the emission sets are empty. This function infers which
+ * emissions have already occurred by examining slot statuses:
+ * - Linear: if stage N is submitted or leased, handoffs 1..N were emitted.
+ * - Merge/merge-all: if assignee X's fan-out slot is submitted/leased, their fan-out request was emitted.
+ * - If all fan-out slots are submitted, the synth request was emitted.
+ * This prevents the reducer from re-emitting stale prompts after restart. */
+function rebuildEmissionState(pipeId: string, projectId: string | null): void {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return;
+
+ if (pipe.mode === 'linear') {
+ // For linear pipes: any stage that is submitted or leased implies its handoff was emitted.
+ // Also, the stage AFTER the last submitted stage was emitted (it's the next handoff target).
+ let maxSubmittedStage = 0;
+ for (const [, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.stage && (slot.status === 'submitted' || slot.status === 'leased')) {
+ if (slot.stage > maxSubmittedStage) maxSubmittedStage = slot.stage;
+ }
+ }
+ }
+ // Handoffs 1..maxSubmittedStage were emitted (each stage received its handoff and acted on it)
+ for (let i = 1; i <= maxSubmittedStage; i++) {
+ pipe.emittedHandoffs.add(i);
+ }
+ } else {
+ // Merge / merge-all / explain / summarize
+ const synthesizer = pipe.assignees[pipe.assignees.length - 1];
+ let allFanOutsSubmitted = true;
+
+ for (const [assignee, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.role === 'fan-out' && (slot.status === 'submitted' || slot.status === 'leased')) {
+ pipe.emittedFanOutRequests.add(assignee);
+ }
+ if (slot.role === 'fan-out' && slot.status !== 'submitted') {
+ allFanOutsSubmitted = false;
+ }
+ }
+ }
+
+ // If all fan-out slots are submitted AND the synthesizer has a leased/submitted final slot,
+ // then the synth request was emitted
+ if (allFanOutsSubmitted) {
+ const synthSlots = pipe.slots.get(synthesizer);
+ if (synthSlots) {
+ const finalSlot = synthSlots.find(s => s.role === 'final');
+ if (finalSlot && (finalSlot.status === 'leased' || finalSlot.status === 'submitted')) {
+ pipe.emittedSynthRequest = true;
+ }
+ }
+ }
+ }
+}
+
+// ── Observability ────────────────────────────────────────────────────────────
+
+export function getPipeTimingSummary(pipeId: string, projectId: string | null): PipeTimingSummary | undefined {
+ const pipe = getPipe(pipeId, projectId);
+ if (!pipe) return undefined;
+ const stages: StageTiming[] = [];
+ let latestSubmission: string | null = null;
+ for (const [assignee, slotList] of pipe.slots) {
+ const lease = getActiveLease(assignee, projectId);
+ for (const slot of slotList) {
+ const isActive = lease?.pipeId === pipeId && lease.slotRole === slot.role && (lease.stage === slot.stage || (lease.stage === undefined && slot.stage === undefined));
+ const grantedAt = isActive ? lease!.grantedAt : null;
+ const deadline = isActive ? lease!.deadline : null;
+ let durationMs: number | null = null;
+ if (grantedAt && slot.submittedAt) durationMs = new Date(slot.submittedAt).getTime() - new Date(grantedAt).getTime();
+ stages.push({ stage: slot.stage, assignee: slot.assignee, role: slot.role, grantedAt, submittedAt: slot.submittedAt, deadline, durationMs });
+ if (slot.submittedAt && (!latestSubmission || slot.submittedAt > latestSubmission)) latestSubmission = slot.submittedAt;
+ }
+ }
+ const completedAt = pipe.status !== 'running' ? latestSubmission : null;
+ const totalDurationMs = completedAt ? new Date(completedAt).getTime() - new Date(pipe.createdAt).getTime() : null;
+ let criticalPathMs: number | null = null;
+ const durations = stages.filter(s => s.durationMs !== null).map(s => ({ role: s.role, ms: s.durationMs! }));
+ if (durations.length > 0) {
+ if (pipe.mode === 'linear') criticalPathMs = durations.reduce((sum, d) => sum + d.ms, 0);
+ else { const fm = Math.max(...durations.filter(d => d.role === 'fan-out').map(d => d.ms), 0); criticalPathMs = fm + (durations.find(d => d.role === 'final')?.ms ?? 0); }
+ }
+ return { pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, createdAt: pipe.createdAt, completedAt, totalDurationMs, stages, criticalPathMs, stageTimeoutMs: pipe.stageTimeoutMs, timeoutPolicy: pipe.timeoutPolicy };
+}
+
+export function getRuntimeLeaseStatuses(projectId: string | null): RuntimeLeaseStatus[] {
+ const nowMs = _pipeClock.now();
+ const result: RuntimeLeaseStatus[] = [];
+ const prefix = (projectId ?? '__none__') + ':';
+ for (const [key, lease] of activeLeases) {
+ if (!key.startsWith(prefix)) continue;
+ const elapsedMs = nowMs - new Date(lease.grantedAt).getTime();
+ const deadlineMs = lease.deadline ? new Date(lease.deadline).getTime() : null;
+ result.push({ pipeId: lease.pipeId, assignee: lease.assignee, slotRole: lease.slotRole, stage: lease.stage, grantedAt: lease.grantedAt, deadline: lease.deadline, elapsedMs, remainingMs: deadlineMs !== null ? deadlineMs - nowMs : null, isOverdue: deadlineMs !== null && nowMs > deadlineMs });
+ }
+ return result;
+}
+
+export function getDeadLetterEntries(projectId: string | null): DeadLetterEntry[] {
+ const nowMs = _pipeClock.now();
+ const entries: DeadLetterEntry[] = [];
+ for (const pipe of getProjectStore(projectId).values()) {
+ if (pipe.status !== 'running') continue;
+ for (const [assignee, slotList] of pipe.slots) {
+ for (const slot of slotList) {
+ if (slot.status === 'submitted') continue;
+ const lease = getActiveLease(assignee, projectId);
+ const isLeased = lease?.pipeId === pipe.pipeId && lease.slotRole === slot.role;
+ if (isLeased && lease!.deadline) {
+ const deadlineMs = new Date(lease!.deadline!).getTime();
+ if (nowMs > deadlineMs) entries.push({ pipeId: pipe.pipeId, assignee, stage: slot.stage, role: slot.role, status: 'timeout-expired', reason: 'Lease expired ' + String(Math.round((nowMs - deadlineMs) / 1000)) + 's ago', grantedAt: lease!.grantedAt, deadline: lease!.deadline, elapsedMs: nowMs - new Date(lease!.grantedAt).getTime(), pipeMode: pipe.mode, pipeStatus: pipe.status });
+ }
+ if (slot.status === 'pending' && !isLeased) {
+ const pipeAge = nowMs - new Date(pipe.createdAt).getTime();
+ const threshold = pipe.stageTimeoutMs > 0 ? pipe.stageTimeoutMs * 2 : 10 * 60 * 1000;
+ if (pipeAge > threshold) entries.push({ pipeId: pipe.pipeId, assignee, stage: slot.stage, role: slot.role, status: 'stuck', reason: 'Pending for ' + String(Math.round(pipeAge / 1000)) + 's', grantedAt: null, deadline: null, elapsedMs: pipeAge, pipeMode: pipe.mode, pipeStatus: pipe.status });
+ }
+ }
+ }
+ }
+ // Delivery-failed: notification retries exhausted
+ for (const delivery of getExhaustedDeliveries(projectId)) {
+ const reason = `Notification exhausted after ${delivery.notifyAttempts} attempts (state: ${delivery.state})`;
+ entries.push({ pipeId: delivery.pipeId, assignee: delivery.assignee, stage: delivery.stage, role: delivery.role, status: 'delivery-failed', reason, grantedAt: delivery.assignedAt, deadline: null, elapsedMs: _pipeClock.now() - new Date(delivery.assignedAt).getTime(), pipeMode: getPipe(delivery.pipeId, projectId)?.mode ?? 'linear', pipeStatus: getPipe(delivery.pipeId, projectId)?.status ?? 'running' });
+ }
+
+ return entries;
+}
+
+export function listAllPipes(projectId: string | null): Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[]; createdAt: string; stageTimeoutMs: number; timeoutPolicy: PipeTimeoutPolicy; slotSummary: { total: number; submitted: number; leased: number; pending: number } }> {
+ const result: Array<{ pipeId: string; mode: PipeMode; status: PipeStatus; assignees: string[]; createdAt: string; stageTimeoutMs: number; timeoutPolicy: PipeTimeoutPolicy; slotSummary: { total: number; submitted: number; leased: number; pending: number } }> = [];
+ for (const pipe of getProjectStore(projectId).values()) {
+ let total = 0, submitted = 0, leased = 0, pending = 0;
+ for (const slotList of pipe.slots.values()) { for (const slot of slotList) { total++; if (slot.status === 'submitted') submitted++; else if (slot.status === 'leased') leased++; else pending++; } }
+ result.push({ pipeId: pipe.pipeId, mode: pipe.mode, status: pipe.status, assignees: pipe.assignees, createdAt: pipe.createdAt, stageTimeoutMs: pipe.stageTimeoutMs, timeoutPolicy: pipe.timeoutPolicy, slotSummary: { total, submitted, leased, pending } });
+ }
+ return result;
+}
+
+// ── Test helper ───────────────────────────────────────────────────────────────
+
+/** Reset all in-memory state. For testing only. */
+export function _resetForTest(): void {
+ _pipeClock = systemClock;
+ stores.clear();
+ activeLeases.clear();
+ pendingPipes.clear();
+ activePipeIndex.clear();
+}
diff --git a/src/apps/chat/services/pipe-tool-usage-regression.test.ts b/src/apps/chat/services/pipe-tool-usage-regression.test.ts
new file mode 100644
index 0000000..a4b3120
--- /dev/null
+++ b/src/apps/chat/services/pipe-tool-usage-regression.test.ts
@@ -0,0 +1,576 @@
+/**
+ * Regression tests for observed LLM pipe tool usage patterns.
+ *
+ * These tests document and verify the cross-cutting behaviors observed when
+ * LLMs interact with the pipe system:
+ *
+ * 1. Delivery state machine when pipe_read_output is skipped vs used
+ * 2. Assignment lifecycle for submit-without-fetch (the legacy/workaround path)
+ * 3. Fan-out payload readability after the auth guard fix
+ * 4. Compact notification wording (assignment vs stage input separation)
+ * 5. Re-notify behavior when fetch is skipped
+ *
+ * These tests operate at the delivery, assignment, and materializer layers
+ * (not the registry integration layer, which is covered by chat-registry.pipe-submit.test.ts).
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as delivery from './pipe-delivery.js';
+import * as materializer from './pipe-assignment-materializer.js';
+import * as assignmentStore from './assignment-store.js';
+import * as payloadStore from './payload-store.js';
+import { createTestClock } from './clock.js';
+
+const PROJECT = 'regression-test';
+
+beforeEach(() => {
+ delivery._resetForTest();
+ assignmentStore._resetForTest();
+ payloadStore._resetForTest();
+});
+
+// ── Observed LLM Pattern: submit without fetch ────────────────────────────────
+
+describe('LLM workaround: submit without pipe_read_output', () => {
+ it('delivery allows direct notified → submitted (skipping fetch)', () => {
+ delivery.createDelivery('pipe-r1', 'alice', 'fan-out-request', 'prompt text', PROJECT);
+ delivery.recordNotification('pipe-r1', 'alice', PROJECT);
+
+ // LLM skips pipe_read_output, submits directly
+ const ok = delivery.recordSubmission('pipe-r1', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-r1', 'alice', PROJECT);
+ expect(record?.state).toBe('submitted');
+ // fetchedAt should remain null — LLM never called pipe_read_output
+ expect(record?.fetchedAt).toBeNull();
+ expect(record?.submittedAt).toBeTruthy();
+ });
+
+ it('delivery allows direct assigned → submitted (fire-and-forget)', () => {
+ delivery.createDelivery('pipe-r2', 'bob', 'fan-out-request', 'prompt', PROJECT);
+
+ // LLM submits without even being notified (race condition / fast agent)
+ const ok = delivery.recordSubmission('pipe-r2', 'bob', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-r2', 'bob', PROJECT);
+ expect(record?.state).toBe('submitted');
+ expect(record?.notifiedAt).toBeNull();
+ expect(record?.fetchedAt).toBeNull();
+ });
+
+ it('assignment completeAssignment handles submit-without-fetch via fast-forward', () => {
+ const result = materializer.materializeAssignment('pipe-r3', 'merge', {
+ type: 'fan-out-request',
+ targetAssignee: 'alice',
+ body: 'Fan-out prompt',
+ }, PROJECT);
+ expect(result).not.toBeNull();
+
+ // Simulate: notification was sent but LLM skipped pipe_read_output
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT);
+
+ // LLM submits — completeAssignment should walk through intermediate states
+ const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(assignment?.status).toBe('notified');
+
+ // Walk manually: notified → acknowledged → payload_fetched → submitted
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+ const ok = materializer.completeAssignment(result!.assignmentId, PROJECT);
+ expect(ok).toBe(true);
+
+ const final = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(final?.status).toBe('submitted');
+ });
+});
+
+// ── Observed LLM Pattern: pipe_get_assignment → chat_read → pipe_submit ───────
+
+describe('LLM workaround: assignment metadata is sufficient for fan-out work', () => {
+ it('fan-out assignment has correct metadata without needing pipe_read_output', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-r4', 'explain', ['alice', 'bob', 'charlie'], 'How does X work?', PROJECT,
+ );
+
+ // All participants get fan-out assignments with metadata
+ expect(results).toHaveLength(3);
+ for (const r of results) {
+ expect(r.role).toBe('fan-out');
+ expect(r.assignmentId).toBeTruthy();
+ expect(r.payloadId).toBeTruthy();
+ expect(r.stageId).toMatch(/^fan-out:/);
+ }
+
+ // Assignment store has the metadata an LLM would get from pipe_get_assignment
+ for (const r of results) {
+ const assignment = assignmentStore.getAssignment(r.assignmentId, PROJECT);
+ expect(assignment).toBeDefined();
+ expect(assignment!.role).toBe('fan-out');
+ expect(assignment!.status).toBe('assigned');
+ expect(assignment!.pipeId).toBe('pipe-r4');
+ }
+ });
+
+ it('fan-out payload content matches the original prompt', () => {
+ const prompt = 'Explain how the pipe system works';
+ const results = materializer.materializePipeAssignments(
+ 'pipe-r5', 'explain', ['alice', 'bob'], prompt, PROJECT,
+ );
+
+ // Each fan-out assignee's payload contains the prompt
+ for (const r of results) {
+ const payload = payloadStore.getPayload(r.payloadId, PROJECT);
+ expect(payload).toBeDefined();
+ expect(payload!.content).toBe(prompt);
+ expect(payload!.status).toBe('active');
+ }
+ });
+
+ it('fan-out payload is fetchable via payload store after assignment creation', () => {
+ const prompt = 'Analyze this code';
+ const results = materializer.materializePipeAssignments(
+ 'pipe-r6', 'merge', ['alice', 'bob', 'carol'], prompt, PROJECT,
+ );
+
+ // merge: alice and bob are fan-out, carol is synthesizer
+ expect(results).toHaveLength(2);
+
+ for (const r of results) {
+ const fetchResult = payloadStore.fetchPayloadContent(r.payloadId, PROJECT);
+ expect(fetchResult.ok).toBe(true);
+ if (fetchResult.ok) {
+ expect(fetchResult.content).toBe(prompt);
+ expect(fetchResult.contentHash).toBeTruthy();
+ }
+ }
+ });
+});
+
+// ── Delivery state integrity when pipe_read_output IS called ──────────────────
+
+describe('correct path: pipe_read_output advances delivery state', () => {
+ it('fetch transitions delivery from notified → fetched', () => {
+ delivery.createDelivery('pipe-r7', 'alice', 'fan-out-request', 'payload', PROJECT);
+ delivery.recordNotification('pipe-r7', 'alice', PROJECT);
+
+ const ok = delivery.recordFetch('pipe-r7', 'alice', PROJECT);
+ expect(ok).toBe(true);
+
+ const record = delivery.getDelivery('pipe-r7', 'alice', PROJECT);
+ expect(record?.state).toBe('fetched');
+ expect(record?.fetchedAt).toBeTruthy();
+ });
+
+ it('fetch after notification advances assignment to payload_fetched', () => {
+ const result = materializer.materializeAssignment('pipe-r8', 'explain', {
+ type: 'fan-out-request',
+ targetAssignee: 'alice',
+ body: 'Explain this',
+ }, PROJECT);
+
+ // Simulate notification + fetch (the correct LLM path)
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+
+ const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(assignment?.status).toBe('payload_fetched');
+ });
+
+ it('full correct lifecycle: assigned → notified → acknowledged → fetched → submitted', () => {
+ const result = materializer.materializeAssignment('pipe-r9', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'bob',
+ stage: 2,
+ body: 'Stage 2 content',
+ }, PROJECT);
+
+ // Delivery lifecycle
+ delivery.createDelivery('pipe-r9', 'bob', 'handoff', 'Stage 2 content', PROJECT, 2);
+ delivery.recordNotification('pipe-r9', 'bob', PROJECT);
+ delivery.recordFetch('pipe-r9', 'bob', PROJECT);
+ delivery.recordSubmission('pipe-r9', 'bob', PROJECT);
+
+ const deliveryRecord = delivery.getDelivery('pipe-r9', 'bob', PROJECT);
+ expect(deliveryRecord?.state).toBe('submitted');
+ expect(deliveryRecord?.fetchedAt).toBeTruthy();
+ expect(deliveryRecord?.submittedAt).toBeTruthy();
+
+ // Assignment lifecycle
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+ const ok = materializer.completeAssignment(result!.assignmentId, PROJECT);
+ expect(ok).toBe(true);
+
+ const assignment = assignmentStore.getAssignment(result!.assignmentId, PROJECT);
+ expect(assignment?.status).toBe('submitted');
+ });
+});
+
+// ── Re-notify behavior when fetch is skipped ──────────────────────────────────
+
+describe('re-notify fires when LLM skips pipe_read_output', () => {
+ it('re-notify timer fires when agent is notified but does not fetch', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-r10', 'alice', 'fan-out-request', 'payload', PROJECT, undefined, {
+ renotifyIntervalMs: 5_000,
+ maxNotifyAttempts: 3,
+ });
+ delivery.recordNotification('pipe-r10', 'alice', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-r10', 'alice', PROJECT, callback);
+
+ // Agent skips pipe_read_output — timer fires after interval
+ vi.advanceTimersByTime(5_000);
+ expect(callback).toHaveBeenCalledWith('pipe-r10', 'alice', PROJECT);
+
+ vi.useRealTimers();
+ });
+
+ it('re-notify timer is cancelled when agent calls pipe_read_output (fetch)', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-r11', 'bob', 'fan-out-request', 'payload', PROJECT, undefined, {
+ renotifyIntervalMs: 5_000,
+ });
+ delivery.recordNotification('pipe-r11', 'bob', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-r11', 'bob', PROJECT, callback);
+
+ // Agent calls pipe_read_output → fetch cancels the timer
+ delivery.recordFetch('pipe-r11', 'bob', PROJECT);
+
+ vi.advanceTimersByTime(10_000);
+ expect(callback).not.toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+
+ it('re-notify does not fire when agent submits directly (skipping fetch)', async () => {
+ vi.useFakeTimers();
+
+ delivery.createDelivery('pipe-r12', 'carol', 'fan-out-request', 'payload', PROJECT, undefined, {
+ renotifyIntervalMs: 5_000,
+ });
+ delivery.recordNotification('pipe-r12', 'carol', PROJECT);
+
+ const callback = vi.fn();
+ delivery.startRenotifyTimer('pipe-r12', 'carol', PROJECT, callback);
+
+ // Agent submits without fetch — recordSubmission cancels the re-notify timer
+ delivery.recordSubmission('pipe-r12', 'carol', PROJECT);
+
+ vi.advanceTimersByTime(10_000);
+ // Timer is cancelled by recordSubmission (via cancelRenotifyTimer), not just suppressed
+ expect(callback).not.toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+});
+
+// ── Compact notification wording regression ───────────────────────────────────
+
+describe('compact notification separates assignment metadata from stage input', () => {
+ it('fan-out notification has separate assignment and input instructions', () => {
+ const notification = delivery.formatCompactNotification(
+ 'abc123', 'explain', 'fan-out-request', 'alice', 3,
+ );
+
+ // Should have separate lines for assignment metadata and stage input
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="abc123")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="abc123")');
+ // Should NOT have the old combined wording
+ expect(notification.body).not.toContain('Read your assignment');
+ });
+
+ it('linear handoff notification has separate assignment and input instructions', () => {
+ const notification = delivery.formatCompactNotification(
+ 'def456', 'linear', 'handoff', 'bob', 3, 2,
+ );
+
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="def456")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="def456")');
+ expect(notification.body).not.toContain('Read your assignment');
+ });
+
+ it('synth-request notification has separate assignment and input instructions', () => {
+ const notification = delivery.formatCompactNotification(
+ 'ghi789', 'merge', 'synth-request', 'synth', 3,
+ );
+
+ expect(notification.body).toContain('Inspect assignment: pipe_get_assignment(pipeId="ghi789")');
+ expect(notification.body).toContain('Read stage input: pipe_read_output(pipeId="ghi789")');
+ expect(notification.body).not.toContain('Read your assignment');
+ });
+
+ it('all notification types include pipe_submit instruction', () => {
+ const modes: Array<{ mode: 'explain' | 'linear' | 'merge'; type: 'fan-out-request' | 'handoff' | 'synth-request'; stage?: number }> = [
+ { mode: 'explain', type: 'fan-out-request' },
+ { mode: 'linear', type: 'handoff', stage: 1 },
+ { mode: 'merge', type: 'synth-request' },
+ ];
+
+ for (const { mode, type, stage } of modes) {
+ const notification = delivery.formatCompactNotification(
+ 'test-pipe', mode, type, 'agent', 3, stage,
+ );
+ expect(notification.body).toContain('pipe_submit(pipeId="test-pipe"');
+ expect(notification.body).toContain('Do not use chat_send');
+ }
+ });
+});
+
+// ── Fan-out payload lifecycle ─────────────────────────────────────────────────
+
+describe('fan-out payload lifecycle across pipe modes', () => {
+ it('explain mode: all participants (including synthesizer) get fan-out payloads', () => {
+ const prompt = 'Explain closures in JS';
+ const results = materializer.materializePipeAssignments(
+ 'pipe-exp', 'explain', ['alice', 'bob', 'charlie'], prompt, PROJECT,
+ );
+
+ expect(results).toHaveLength(3);
+ for (const r of results) {
+ const payload = payloadStore.getPayload(r.payloadId, PROJECT);
+ expect(payload!.content).toBe(prompt);
+ expect(payload!.status).toBe('active');
+ }
+ });
+
+ it('merge mode: only non-synthesizer participants get fan-out payloads', () => {
+ const prompt = 'Compare approaches';
+ const results = materializer.materializePipeAssignments(
+ 'pipe-mrg', 'merge', ['alice', 'bob', 'carol'], prompt, PROJECT,
+ );
+
+ // merge: alice and bob are fan-out, carol is synthesizer (no initial payload)
+ expect(results).toHaveLength(2);
+ const assignees = results.map(r => r.assignee);
+ expect(assignees).toContain('alice');
+ expect(assignees).toContain('bob');
+ expect(assignees).not.toContain('carol');
+ });
+
+ it('payloads are archived when pipe assignments are cancelled', () => {
+ const results = materializer.materializePipeAssignments(
+ 'pipe-cancel', 'explain', ['alice', 'bob'], 'prompt', PROJECT,
+ );
+
+ materializer.cancelPipeAssignments('pipe-cancel', PROJECT);
+
+ // Payloads should be archived
+ for (const r of results) {
+ const payload = payloadStore.getPayload(r.payloadId, PROJECT);
+ expect(payload!.status).toBe('archived');
+ }
+ });
+
+ it('payload is archived after assignment completion', () => {
+ const result = materializer.materializeAssignment('pipe-complete', 'linear', {
+ type: 'handoff',
+ targetAssignee: 'alice',
+ stage: 1,
+ body: 'Do work',
+ }, PROJECT);
+
+ // Walk through lifecycle to submitted
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(result!.assignmentId, 'payload_fetched', PROJECT);
+ materializer.completeAssignment(result!.assignmentId, PROJECT);
+
+ const payload = payloadStore.getPayload(result!.payloadId, PROJECT);
+ expect(payload!.status).toBe('archived');
+ });
+});
+
+// ── Cross-layer consistency: delivery + assignment + payload ───────────────────
+
+describe('cross-layer consistency for fan-out pipe', () => {
+ it('all three layers stay consistent through the correct path', () => {
+ const pipeId = 'pipe-consistent';
+ const assignee = 'alice';
+ const prompt = 'Explain the bug';
+
+ // 1. Materialize assignment + payload
+ const materialized = materializer.materializeAssignment(pipeId, 'explain', {
+ type: 'fan-out-request',
+ targetAssignee: assignee,
+ body: prompt,
+ }, PROJECT);
+ expect(materialized).not.toBeNull();
+
+ // 2. Create delivery record
+ delivery.createDelivery(pipeId, assignee, 'fan-out-request', prompt, PROJECT);
+
+ // 3. Notification sent
+ delivery.recordNotification(pipeId, assignee, PROJECT);
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'notified', PROJECT);
+
+ // Verify: all layers in 'notified' state
+ expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('notified');
+ expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('notified');
+ expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('active');
+
+ // 4. Agent fetches (pipe_read_output)
+ delivery.recordFetch(pipeId, assignee, PROJECT);
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'payload_fetched', PROJECT);
+
+ // Verify: delivery fetched, assignment payload_fetched, payload active
+ expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('fetched');
+ expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('payload_fetched');
+ expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('active');
+
+ // 5. Agent submits
+ delivery.recordSubmission(pipeId, assignee, PROJECT);
+ materializer.completeAssignment(materialized!.assignmentId, PROJECT);
+
+ // Verify: all layers in terminal state
+ expect(delivery.getDelivery(pipeId, assignee, PROJECT)?.state).toBe('submitted');
+ expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('submitted');
+ expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('archived');
+ });
+
+ it('all three layers stay consistent through the workaround path (skip fetch)', () => {
+ const pipeId = 'pipe-workaround';
+ const assignee = 'bob';
+ const prompt = 'What is happening?';
+
+ // 1. Materialize assignment + payload
+ const materialized = materializer.materializeAssignment(pipeId, 'explain', {
+ type: 'fan-out-request',
+ targetAssignee: assignee,
+ body: prompt,
+ }, PROJECT);
+
+ // 2. Create delivery record
+ delivery.createDelivery(pipeId, assignee, 'fan-out-request', prompt, PROJECT);
+
+ // 3. Notification sent
+ delivery.recordNotification(pipeId, assignee, PROJECT);
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'notified', PROJECT);
+
+ // 4. Agent SKIPS pipe_read_output — goes directly to submit
+ delivery.recordSubmission(pipeId, assignee, PROJECT);
+
+ // Assignment needs manual walk-through since completeAssignment from 'notified'
+ // requires walking through intermediate states
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(materialized!.assignmentId, 'payload_fetched', PROJECT);
+ materializer.completeAssignment(materialized!.assignmentId, PROJECT);
+
+ // Verify: delivery submitted (no fetch), assignment submitted, payload archived
+ const deliveryRecord = delivery.getDelivery(pipeId, assignee, PROJECT);
+ expect(deliveryRecord?.state).toBe('submitted');
+ expect(deliveryRecord?.fetchedAt).toBeNull(); // never fetched
+ expect(deliveryRecord?.submittedAt).toBeTruthy();
+
+ expect(assignmentStore.getAssignment(materialized!.assignmentId, PROJECT)?.status).toBe('submitted');
+ expect(payloadStore.getPayload(materialized!.payloadId, PROJECT)?.status).toBe('archived');
+ });
+});
+
+// ── Synthesizer payload lifecycle ─────────────────────────────────────────────
+
+describe('synthesizer: correct pipe_read_output usage', () => {
+ it('synth assignment materializes with collected outputs as payload', () => {
+ const synthBody = '--- @alice ---\nAlice analysis\n\n--- @bob ---\nBob analysis';
+ const result = materializer.materializeSynthAssignment(
+ 'pipe-synth', 'explain', 'charlie', synthBody, PROJECT,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.role).toBe('final');
+ expect(result!.stageId).toBe('synth');
+
+ const payload = payloadStore.getPayload(result!.payloadId, PROJECT);
+ expect(payload!.content).toBe(synthBody);
+ });
+
+ it('synth delivery lifecycle tracks fetch correctly', () => {
+ const synthBody = 'Synthesize these outputs';
+
+ delivery.createDelivery('pipe-synth2', 'synth', 'synth-request', synthBody, PROJECT);
+ delivery.recordNotification('pipe-synth2', 'synth', PROJECT);
+
+ // Synthesizer MUST call pipe_read_output to get fan-out outputs
+ delivery.recordFetch('pipe-synth2', 'synth', PROJECT);
+
+ const record = delivery.getDelivery('pipe-synth2', 'synth', PROJECT);
+ expect(record?.state).toBe('fetched');
+ expect(record?.fetchedAt).toBeTruthy();
+
+ delivery.recordSubmission('pipe-synth2', 'synth', PROJECT);
+ expect(delivery.getDelivery('pipe-synth2', 'synth', PROJECT)?.state).toBe('submitted');
+ });
+});
+
+// ── Linear pipe: pipe_read_output is essential for stage 2+ ───────────────────
+
+describe('linear pipe: pipe_read_output is essential for downstream stages', () => {
+ it('stage 2 assignment payload contains the compact notification (not upstream output)', () => {
+ // The materializer stores the compact notification as the payload body
+ // The actual upstream output is served by readPipeOutput in chat-registry
+ const result = materializer.materializeNextLinearAssignment(
+ 'pipe-lin', 1, ['alice', 'bob', 'carol'], 'Stage 1 output from alice', PROJECT,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result!.assignee).toBe('bob');
+ expect(result!.stage).toBe(2);
+
+ // Payload contains what was passed as the body (stage 1 output)
+ const payload = payloadStore.getPayload(result!.payloadId, PROJECT);
+ expect(payload!.content).toBe('Stage 1 output from alice');
+ });
+
+ it('linear assignment + delivery + payload all track through full lifecycle', () => {
+ // Materialize stage 1
+ const s1 = materializer.materializePipeAssignments(
+ 'pipe-lin-full', 'linear', ['alice', 'bob'], 'Original prompt', PROJECT,
+ );
+ expect(s1).toHaveLength(1);
+
+ // Complete stage 1 through full lifecycle
+ delivery.createDelivery('pipe-lin-full', 'alice', 'handoff', 'Original prompt', PROJECT, 1);
+ delivery.recordNotification('pipe-lin-full', 'alice', PROJECT);
+ delivery.recordFetch('pipe-lin-full', 'alice', PROJECT);
+ delivery.recordSubmission('pipe-lin-full', 'alice', PROJECT);
+
+ materializer.transitionAssignmentStatus(s1[0].assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(s1[0].assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(s1[0].assignmentId, 'payload_fetched', PROJECT);
+ materializer.completeAssignment(s1[0].assignmentId, PROJECT);
+
+ // Materialize stage 2
+ const s2 = materializer.materializeNextLinearAssignment(
+ 'pipe-lin-full', 1, ['alice', 'bob'], 'Alice stage 1 output', PROJECT,
+ );
+ expect(s2).not.toBeNull();
+ expect(s2!.assignee).toBe('bob');
+ expect(s2!.stage).toBe(2);
+
+ // Stage 2 delivery lifecycle (bob calls pipe_read_output for upstream content)
+ delivery.createDelivery('pipe-lin-full', 'bob', 'handoff', 'Alice stage 1 output', PROJECT, 2);
+ delivery.recordNotification('pipe-lin-full', 'bob', PROJECT);
+ delivery.recordFetch('pipe-lin-full', 'bob', PROJECT);
+ delivery.recordSubmission('pipe-lin-full', 'bob', PROJECT);
+
+ materializer.transitionAssignmentStatus(s2!.assignmentId, 'notified', PROJECT);
+ materializer.transitionAssignmentStatus(s2!.assignmentId, 'acknowledged', PROJECT);
+ materializer.transitionAssignmentStatus(s2!.assignmentId, 'payload_fetched', PROJECT);
+ materializer.completeAssignment(s2!.assignmentId, PROJECT);
+
+ // Verify both stages completed
+ expect(assignmentStore.getAssignment(s1[0].assignmentId, PROJECT)?.status).toBe('submitted');
+ expect(assignmentStore.getAssignment(s2!.assignmentId, PROJECT)?.status).toBe('submitted');
+ expect(payloadStore.getPayload(s1[0].payloadId, PROJECT)?.status).toBe('archived');
+ expect(payloadStore.getPayload(s2!.payloadId, PROJECT)?.status).toBe('archived');
+ });
+});
diff --git a/src/apps/chat/services/terminal-utils.ts b/src/apps/chat/services/terminal-utils.ts
new file mode 100644
index 0000000..ff8ce6e
--- /dev/null
+++ b/src/apps/chat/services/terminal-utils.ts
@@ -0,0 +1,21 @@
+/**
+ * Shared terminal text helpers for PTY output analysis.
+ * Used by both chat-registry (prompt watcher / status tracking) and
+ * the chat router (shell readiness detection for invite).
+ */
+
+/** Regex to strip ANSI escape sequences and carriage returns from terminal output. */
+export const STRIP_ANSI_RE = /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g;
+
+/** Strip ANSI escape sequences and carriage returns from text. */
+export function stripAnsi(str: string): string {
+ return str.replace(STRIP_ANSI_RE, '');
+}
+
+/** Matches common shell prompt endings: $, #, %, >, ⚡ */
+export const SHELL_PROMPT_RE = /[>$#%⚡]\s*$/m;
+
+/** Returns true if text contains a shell prompt after ANSI stripping. */
+export function hasShellPrompt(text: string): boolean {
+ return SHELL_PROMPT_RE.test(stripAnsi(text));
+}
diff --git a/src/apps/chat/src/index.ts b/src/apps/chat/src/index.ts
new file mode 100644
index 0000000..cd4e549
--- /dev/null
+++ b/src/apps/chat/src/index.ts
@@ -0,0 +1,10 @@
+#!/usr/bin/env node
+import { createChatMcpServer } from "./mcp.js";
+import { runStdio } from "../../../packages/mcp-utils/src/index.js";
+
+// ── Stdio MCP mode ──────────────────────────────────────────────────────────
+if (process.argv.includes("--stdio")) {
+ const server = createChatMcpServer();
+ await runStdio(server);
+ console.error("Devglide Chat MCP server running on stdio");
+}
diff --git a/src/apps/chat/src/mcp.test.ts b/src/apps/chat/src/mcp.test.ts
new file mode 100644
index 0000000..605f90e
--- /dev/null
+++ b/src/apps/chat/src/mcp.test.ts
@@ -0,0 +1,343 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const registeredTools = vi.hoisted(() => new Map Promise>());
+
+const createDevglideMcpServerMock = vi.hoisted(() => vi.fn(() => {
+ const server = {
+ tool: vi.fn((name: string, _description: string, _schema: unknown, handler: (args: any) => Promise) => {
+ registeredTools.set(name, handler);
+ }),
+ };
+ return server;
+}));
+
+vi.mock('../../../packages/mcp-utils/src/index.js', () => ({
+ createDevglideMcpServer: createDevglideMcpServerMock,
+ jsonResult: (data: unknown) => ({
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
+ }),
+ errorResult: (message: string) => ({
+ content: [{ type: 'text', text: message }],
+ isError: true,
+ }),
+}));
+
+vi.mock('../services/chat-store.js', () => ({
+ readMessages: vi.fn(() => []),
+}));
+
+vi.mock('../services/chat-rules.js', () => ({
+ getEffectiveRules: vi.fn(() => '## Rules'),
+}));
+
+function mockJsonResponse(ok: boolean, status: number, data: unknown) {
+ return {
+ ok,
+ status,
+ json: vi.fn(async () => data),
+ };
+}
+
+function parseJsonResult(result: { content: Array<{ text: string }> }) {
+ return JSON.parse(result.content[0]!.text);
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe('chat MCP session ownership', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ registeredTools.clear();
+ });
+
+ it('rejects a second join on the same live MCP session', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true,
+ name: 'alpha-1',
+ paneId: 'pane-1',
+ detached: false,
+ projectId: 'project-1',
+ }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ expect(chatJoin).toBeTypeOf('function');
+
+ const first = await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+ const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' });
+
+ expect(parseJsonResult(first)).toMatchObject({ name: 'alpha-1', projectId: 'project-1' });
+ expect(second.isError).toBe(true);
+ expect(second.content[0]!.text).toContain('already joined as "alpha-1"');
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/join');
+ expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/status?name=alpha-1&projectId=project-1');
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]);
+ });
+
+ it('allows a new join when the tracked participant is already gone', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }))
+ .mockResolvedValueOnce(mockJsonResponse(false, 404, { error: 'Participant "alpha-1" not found', joined: false }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ expect(chatJoin).toBeTypeOf('function');
+
+ await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+ const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' });
+
+ expect(second.isError).not.toBe(true);
+ expect(parseJsonResult(second)).toMatchObject({ name: 'beta-2', projectId: 'project-1' });
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]);
+ });
+
+ it('allows a new join when the tracked participant is detached', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true,
+ name: 'alpha-1',
+ paneId: 'pane-1',
+ detached: true,
+ projectId: 'project-1',
+ }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ expect(chatJoin).toBeTypeOf('function');
+
+ await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+ const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' });
+
+ expect(second.isError).not.toBe(true);
+ expect(parseJsonResult(second)).toMatchObject({ name: 'beta-2', projectId: 'project-1' });
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]);
+ });
+
+ it('rejects overlapping joins on the same MCP session before a second participant is created', async () => {
+ const joinResponse = deferred>();
+ const fetchMock = vi.fn(() => joinResponse.promise);
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ expect(chatJoin).toBeTypeOf('function');
+
+ const firstJoinPromise = chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+ const second = await chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' });
+
+ expect(second.isError).toBe(true);
+ expect(second.content[0]!.text).toContain('already in progress');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ joinResponse.resolve(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }));
+ const first = await firstJoinPromise;
+
+ expect(parseJsonResult(first)).toMatchObject({ name: 'alpha-1', projectId: 'project-1' });
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]);
+ });
+
+ it('rejects overlapping joins while stale-session recovery is still in progress', async () => {
+ const statusResponse = deferred>();
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }))
+ .mockImplementationOnce(() => statusResponse.promise)
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'beta-2', projectId: 'project-1' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ expect(chatJoin).toBeTypeOf('function');
+
+ await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+
+ const firstRecoveryJoin = chatJoin!({ name: 'beta', paneId: 'pane-2', submitKey: 'cr' });
+ const secondRecoveryJoin = await chatJoin!({ name: 'gamma', paneId: 'pane-3', submitKey: 'cr' });
+
+ expect(secondRecoveryJoin.isError).toBe(true);
+ expect(secondRecoveryJoin.content[0]!.text).toContain('already in progress');
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ statusResponse.resolve(mockJsonResponse(false, 404, { error: 'Participant "alpha-1" not found', joined: false }));
+ const recovered = await firstRecoveryJoin;
+
+ expect(parseJsonResult(recovered)).toMatchObject({ name: 'beta-2', projectId: 'project-1' });
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'beta-2', projectId: 'project-1', paneId: 'pane-2' }]);
+ });
+
+ it('adopts an existing REST-joined participant by paneId before sending', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true,
+ name: 'alpha-1',
+ paneId: 'pane-1',
+ detached: false,
+ projectId: 'project-1',
+ }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { id: 'msg-1' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer, chatServerSessions } = await import('./mcp.js');
+ const server = createChatMcpServer();
+ const chatSend = registeredTools.get('chat_send');
+ expect(chatSend).toBeTypeOf('function');
+
+ const result = await chatSend!({ message: 'hello', paneId: 'pane-1' });
+
+ expect(result.isError).not.toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/status?paneId=pane-1');
+ expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/send');
+ expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body))).toMatchObject({
+ from: 'alpha-1',
+ projectId: 'project-1',
+ message: 'hello',
+ });
+ expect(chatServerSessions.get(server as never)).toEqual([{ name: 'alpha-1', projectId: 'project-1', paneId: 'pane-1' }]);
+ });
+
+ it('pipe_read_output adopts session by paneId and sends X-Pane-Id header', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true,
+ name: 'alpha-1',
+ paneId: 'pane-1',
+ detached: false,
+ projectId: 'project-1',
+ }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ pipeId: 'abc123',
+ mode: 'linear',
+ previousOutput: { stage: 1, from: 'other', content: 'stage 1 work' },
+ }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer } = await import('./mcp.js');
+ createChatMcpServer();
+ const pipeReadOutput = registeredTools.get('pipe_read_output');
+ expect(pipeReadOutput).toBeTypeOf('function');
+
+ const result = await pipeReadOutput!({ pipeId: '#pipe-abc123', paneId: 'pane-1' });
+
+ expect(result.isError).not.toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ // First call: adopt session via /status?paneId=
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('/api/chat/status?paneId=pane-1');
+ // Second call: GET /pipes/:id/output with X-Pane-Id header
+ const outputUrl = String(fetchMock.mock.calls[1]![0]);
+ expect(outputUrl).toContain('/api/chat/pipes/abc123/output');
+ expect(outputUrl).not.toContain('from=');
+ const headers = fetchMock.mock.calls[1]![1]?.headers as Record;
+ expect(headers['x-pane-id']).toBe('pane-1');
+ });
+
+ it('pipe_read_output returns error when not joined', async () => {
+ vi.stubGlobal('fetch', vi.fn());
+
+ const { createChatMcpServer } = await import('./mcp.js');
+ createChatMcpServer();
+ const pipeReadOutput = registeredTools.get('pipe_read_output');
+ expect(pipeReadOutput).toBeTypeOf('function');
+
+ const result = await pipeReadOutput!({ pipeId: 'abc123' });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0]!.text).toContain('Not joined');
+ });
+
+ it('pipe_read_output returns error when no pane ID available', async () => {
+ // Join first (no paneId in response, paneId arg not passed to join)
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { name: 'alpha-1', projectId: 'project-1' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer } = await import('./mcp.js');
+ createChatMcpServer();
+ const chatJoin = registeredTools.get('chat_join');
+ await chatJoin!({ name: 'alpha', paneId: 'pane-1', submitKey: 'cr' });
+
+ // Now call pipe_read_output — session has paneId from join arg fallback
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ pipeId: 'abc123', mode: 'linear',
+ previousOutput: { stage: 1, from: 'other', content: 'output' },
+ }));
+ const pipeReadOutput = registeredTools.get('pipe_read_output');
+ const result = await pipeReadOutput!({ pipeId: 'abc123' });
+
+ expect(result.isError).not.toBe(true);
+ const headers = fetchMock.mock.calls[1]![1]?.headers as Record;
+ expect(headers['x-pane-id']).toBe('pane-1');
+ });
+
+ it('pipe_read_output forwards REST errors', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true, name: 'alpha-1', paneId: 'pane-1', detached: false, projectId: 'project-1',
+ }))
+ .mockResolvedValueOnce(mockJsonResponse(false, 403, { error: 'Not an assignee' }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer } = await import('./mcp.js');
+ createChatMcpServer();
+ const pipeReadOutput = registeredTools.get('pipe_read_output');
+ expect(pipeReadOutput).toBeTypeOf('function');
+
+ const result = await pipeReadOutput!({ pipeId: 'abc123', paneId: 'pane-1' });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0]!.text).toContain('Not an assignee');
+ });
+
+ it('adopts an existing REST-joined participant by paneId before pipe submit', async () => {
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(mockJsonResponse(true, 200, {
+ joined: true,
+ name: 'alpha-1',
+ paneId: 'pane-1',
+ detached: false,
+ projectId: 'project-1',
+ }))
+ .mockResolvedValueOnce(mockJsonResponse(true, 201, { ok: true }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { createChatMcpServer } = await import('./mcp.js');
+ createChatMcpServer();
+ const pipeSubmit = registeredTools.get('pipe_submit');
+ expect(pipeSubmit).toBeTypeOf('function');
+
+ const result = await pipeSubmit!({ pipeId: '#pipe-abc123', content: 'artifact', paneId: 'pane-1' });
+
+ expect(result.isError).not.toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(String(fetchMock.mock.calls[1]![0])).toContain('/api/chat/pipes/abc123/submit');
+ expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body))).toMatchObject({
+ from: 'alpha-1',
+ projectId: 'project-1',
+ content: 'artifact',
+ });
+ });
+});
diff --git a/src/apps/chat/src/mcp.ts b/src/apps/chat/src/mcp.ts
new file mode 100644
index 0000000..e586cf5
--- /dev/null
+++ b/src/apps/chat/src/mcp.ts
@@ -0,0 +1,530 @@
+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { z } from 'zod';
+import { jsonResult, errorResult, createDevglideMcpServer } from '../../../packages/mcp-utils/src/index.js';
+import * as store from '../services/chat-store.js';
+import { getEffectiveRules } from '../services/chat-rules.js';
+
+const UNIFIED_BASE = `http://localhost:${process.env.PORT ?? 7000}`;
+
+export interface ChatSessionEntry { name: string; projectId: string | null; paneId?: string | null }
+interface ChatMcpServerState {
+ sessionEntry: ChatSessionEntry | null;
+ joinInFlight: boolean;
+}
+
+/** Maps each per-session McpServer instance to its tracked chat participant(s).
+ * New code keeps this to a single entry per MCP session, but the array shape is retained
+ * so onSessionClose can safely clean up stale sessions from older builds. */
+export const chatServerSessions = new WeakMap();
+const chatMcpServerStates = new WeakMap();
+const chatMcpServersBySessionId = new Map();
+
+interface ChatStatusPayload {
+ joined?: boolean;
+ detached?: boolean;
+ paneId?: string | null;
+ error?: string;
+}
+
+/** POST/GET helper for the unified server's chat REST API. */
+async function chatApi(path: string, body?: unknown, extraHeaders?: Record): Promise<{ ok: boolean; status: number; data: unknown }> {
+ const opts: RequestInit = {
+ headers: { 'Content-Type': 'application/json', ...extraHeaders },
+ };
+ if (body !== undefined) {
+ opts.method = 'POST';
+ opts.body = JSON.stringify(body);
+ }
+ const res = await fetch(`${UNIFIED_BASE}/api/chat${path}`, opts);
+ const data = await res.json();
+ return { ok: res.ok, status: res.status, data };
+}
+
+/** Reverse-lookup: find the MCP session ID for a given server instance. */
+function getMcpSessionId(server: McpServer): string | undefined {
+ for (const [id, s] of chatMcpServersBySessionId) {
+ if (s === server) return id;
+ }
+ return undefined;
+}
+
+function getServerState(server: McpServer): ChatMcpServerState {
+ let state = chatMcpServerStates.get(server);
+ if (!state) {
+ state = { sessionEntry: null, joinInFlight: false };
+ chatMcpServerStates.set(server, state);
+ }
+ return state;
+}
+
+function setTrackedSessionEntry(server: McpServer, entry: ChatSessionEntry | null): void {
+ const state = getServerState(server);
+ state.sessionEntry = entry;
+ if (entry) {
+ chatServerSessions.set(server, [{ ...entry }]);
+ return;
+ }
+ chatServerSessions.delete(server);
+}
+
+export function registerChatMcpHttpSession(sessionId: string, server: McpServer): void {
+ chatMcpServersBySessionId.set(sessionId, server);
+ getServerState(server);
+}
+
+export function unregisterChatMcpHttpSession(server: McpServer, sessionId?: string): void {
+ if (sessionId) {
+ if (chatMcpServersBySessionId.get(sessionId) === server) chatMcpServersBySessionId.delete(sessionId);
+ return;
+ }
+ for (const [id, trackedServer] of chatMcpServersBySessionId) {
+ if (trackedServer === server) chatMcpServersBySessionId.delete(id);
+ }
+}
+
+export function bindChatSessionToMcpHttpSession(sessionId: string, entry: ChatSessionEntry | null): boolean {
+ const server = chatMcpServersBySessionId.get(sessionId);
+ if (!server) return false;
+ setTrackedSessionEntry(server, entry);
+ return true;
+}
+
+export function hasChatMcpHttpSession(sessionId: string): boolean {
+ return chatMcpServersBySessionId.has(sessionId);
+}
+
+export function createChatMcpServer(): McpServer {
+ const server = createDevglideMcpServer(
+ 'devglide-chat',
+ '0.1.0',
+ 'Multi-LLM chat room for cross-agent communication',
+ {
+ instructions: [
+ '## Chat — Usage Conventions',
+ '',
+ '### Purpose',
+ '- Chat provides a shared room where the user and multiple LLM instances communicate.',
+ '- Messages are **broadcast within the active project** so every participant stays current.',
+ '- LLMs receive messages via PTY injection when linked to a shell pane.',
+ '',
+ '### Joining',
+ '- Use `chat_join` to register as a participant. Provide your `name` (e.g. "claude", "codex") and optionally `model` (e.g. "claude-sonnet-4-6", "gpt-5").',
+ '- **Name assignment:** The server derives your chat alias from `name` + pane number (e.g. "claude-1" for name "claude" on pane 1). The `name` param is the identity base — use a stable agent label, not the backend model. **Always use the `name` returned by `chat_join`** — that is your identity for the session.',
+ '- `"user"` and `"system"` are **reserved names** — do not use them.',
+ '- `chat_join` requires an explicit `paneId`. Read `DEVGLIDE_PANE_ID` from your shell session and pass it as `paneId` every time. Do not use `"auto"` and do not rely on MCP process env inheritance.',
+ '- If your paneId collides with another participant, the **existing session is preserved** and the newcomer receives a 409 error with `code: "PANE_ALREADY_BOUND"`. The newcomer must use a different pane or wait for the existing participant to leave.',
+ '- **`submitKey` parameter:** Controls the character sent after PTY-injected messages to trigger input submission. Use `"cr"` (carriage return, default) for all known clients including Claude Code and Codex. The submit key is sent after a short delay to avoid paste-burst detection in TUI frameworks like crossterm.',
+ '- Each MCP session may own only one chat participant. Use `chat_leave()` first, or create a separate MCP session for another agent.',
+ '',
+ '### Rules of Engagement',
+ '- On `chat_join`, you receive a `rules` field containing the project\'s **Rules of Engagement** (markdown).',
+ '- **Follow these rules exactly** — they define when you should respond and when to stay silent.',
+ '- Default rule: reply if @mentioned, or if the user makes a global request only after your claim has been explicitly confirmed by the other active LLM participants. Do not let multiple LLMs answer the same global request uncoordinated.',
+ '- Rules can be customized per project. Always follow the rules returned by `chat_join`.',
+ '',
+ '### Sending messages',
+ '- Use `chat_send` to send a message. Use **@mentions in the message body** to address specific participants (e.g. `@user check this`).',
+ '- **Targeted PTY delivery:** Delivery recipients are resolved from the `to` param plus any `@mentions` in the message body. Use `@all` as an explicit broadcast token to reach all participants. LLM messages with no recipients in either `to` or body @mentions are persisted in history but not PTY-delivered to any agent terminal.',
+ '- Never @mention yourself — messages are never delivered back to the sender.',
+ '- Markdown is supported in message bodies.',
+ '',
+ '### Reading history',
+ '- Use `chat_read` to read recent message history. Supports `limit` and `since` filters.',
+ '- Use `chat_members` to list active participants and check their pane link status (`paneId: null` means disconnected).',
+ '',
+ '### Pane linking',
+ '- A valid `paneId` is required to receive messages via PTY injection.',
+ '- `chat_join` now fails if the supplied pane is missing or not routable by the shell backend.',
+ '- If your pane closes, you are automatically removed from the chat.',
+ '',
+ '### Limitations',
+ '- You cannot send messages to yourself (self-mentions are ignored).',
+ '- Only participants in the same project see each other and can exchange messages.',
+ '- The `to` param and body @mentions are both merged to build the delivery target set for all senders including LLMs. Leaving both empty means no PTY delivery for LLM senders.',
+ '- Participants are in-memory only — if the server restarts, everyone must rejoin.',
+ '',
+ '### Quick reference — commonly confused parameters',
+ '- `chat_join(name, model?, paneId, submitKey?)` — register. `paneId` is required and must come from `DEVGLIDE_PANE_ID` in your shell (never `"auto"`). Check returned `name` (server assigns it). `"user"`/`"system"` reserved. `submitKey`: `"cr"` (default, correct for all known clients including Claude Code and Codex).',
+ '- `chat_leave(paneId?)` — unregister from the chat room. Pass `paneId` if this MCP session has no tracked state (e.g. after a REST-only join).',
+ '- `chat_send(message, to?, paneId?)` — send a message. Delivery goes to recipients resolved from `to` plus body @mentions; use `@all` to broadcast to all participants. LLM messages with no recipients in either field are persisted but not PTY-delivered. Messages that start with `#pipe-` or reference a currently running `#pipe-*` are rejected — use `pipe_submit` instead. Pass `paneId` to adopt a REST-joined session.',
+ '- `pipe_submit(pipeId, content, paneId?)` — submit your output for a pipe stage. Use this instead of `chat_send` when responding to a `#pipe-` prompt. Pass `paneId` to adopt a REST-joined session.',
+ '- `pipe_get_assignment(pipeId, paneId?)` — inspect assignment metadata (role, stage, lease status, deadline). Use this to confirm what you are assigned to do. Does not return stage content.',
+ '- `pipe_read_output(pipeId, paneId?)` — read the stage input content you are entitled to (previous stage output for linear, original prompt for fan-out, aggregated fan-out outputs for synthesizer). Caller identity resolved from session.',
+ '- `chat_read(limit?, since?)` — read message history.',
+ '- `chat_members()` — list active participants with pane link status.',
+ ],
+ },
+ );
+
+ function setSessionEntry(entry: ChatSessionEntry | null): void {
+ setTrackedSessionEntry(server, entry);
+ }
+
+ function getSessionEntry(): ChatSessionEntry | null {
+ return getServerState(server).sessionEntry;
+ }
+
+ function setJoinInFlight(value: boolean): void {
+ getServerState(server).joinInFlight = value;
+ }
+
+ function getJoinInFlight(): boolean {
+ return getServerState(server).joinInFlight;
+ }
+
+ function getSessionProjectId(): string | null {
+ return getSessionEntry()?.projectId ?? null;
+ }
+
+ function getSessionName(): string | null {
+ return getSessionEntry()?.name ?? null;
+ }
+
+ async function readTrackedParticipantStatus(entry: ChatSessionEntry): Promise<{ ok: boolean; status: number; data: ChatStatusPayload } | null> {
+ const query = `?name=${encodeURIComponent(entry.name)}${entry.projectId ? `&projectId=${encodeURIComponent(entry.projectId)}` : ''}`;
+ try {
+ const res = await chatApi(`/status${query}`);
+ return { ok: res.ok, status: res.status, data: (res.data as ChatStatusPayload) ?? {} };
+ } catch {
+ return null;
+ }
+ }
+
+ async function ensureSessionCanJoin(): Promise<{ ok: true } | { ok: false; result: ReturnType }> {
+ const sessionEntry = getSessionEntry();
+ if (!sessionEntry) return { ok: true };
+
+ const status = await readTrackedParticipantStatus(sessionEntry);
+ if (!status) {
+ return {
+ ok: false,
+ result: errorResult(
+ `This MCP session is already joined as "${sessionEntry.name}", and its current state could not be verified. Use chat_leave first or create a separate MCP session for another participant.`,
+ ),
+ };
+ }
+ if (!status.ok) {
+ if (status.status === 404) {
+ setSessionEntry(null);
+ return { ok: true };
+ }
+ return {
+ ok: false,
+ result: errorResult(
+ `This MCP session is already joined as "${sessionEntry.name}". Status check failed: ${status.data.error ?? 'unknown error'}. Use chat_leave first or create a separate MCP session for another participant.`,
+ ),
+ };
+ }
+
+ if (status.data.joined === false || status.data.detached || !status.data.paneId) {
+ setSessionEntry(null);
+ return { ok: true };
+ }
+
+ return {
+ ok: false,
+ result: errorResult(
+ `This MCP session is already joined as "${sessionEntry.name}". Use chat_leave first or create a separate MCP session for another participant.`,
+ ),
+ };
+ }
+
+ async function tryAdoptSessionByPaneId(paneId?: string): Promise {
+ const existing = getSessionEntry();
+ if (existing || !paneId) return existing;
+
+ const res = await chatApi(`/status?paneId=${encodeURIComponent(paneId)}`).catch(() => null);
+ if (!res?.ok) return null;
+
+ const data = (res.data as ChatStatusPayload & { name?: string; projectId?: string | null }) ?? {};
+ if (!data.joined || !data.name || data.detached || !data.paneId) return null;
+
+ const adopted = { name: data.name, projectId: data.projectId ?? null, paneId };
+ setSessionEntry(adopted);
+ return adopted;
+ }
+
+ // ── 1. chat_join ──────────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_join',
+ 'Join the chat room as a participant. Requires explicit paneId — read $DEVGLIDE_PANE_ID from your shell session and pass it directly. Do not use "auto" or omit paneId.',
+ {
+ name: z.string().describe('Stable agent identity label used as the base for your chat alias (e.g. "claude", "codex", "cursor"). Do not pass the backend model here — use a consistent short name.'),
+ model: z.string().optional().describe('Backend model identifier for display (e.g. "claude-sonnet-4-6", "gpt-5"). Not used for name derivation — use `name` for identity.'),
+ paneId: z.string().describe('Shell pane ID for PTY delivery. Read DEVGLIDE_PANE_ID from your shell session and pass it directly. Do not use "auto" — the server will not guess your pane.'),
+ submitKey: z.enum(['cr', 'lf']).optional().describe('Character to trigger submit after PTY injection: "cr" (default, correct for all known clients including Claude Code and Codex). Only use "lf" if you have verified a specific client requires it'),
+ },
+ async ({ name, model, paneId, submitKey }) => {
+ if (name === 'user') return errorResult('"user" is reserved for the dashboard user');
+ if (name === 'system') return errorResult('"system" is reserved');
+
+ // Reject "auto" — LLMs must pass their actual pane ID
+ if (paneId === 'auto') {
+ return errorResult(
+ 'chat_join requires an explicit paneId for LLM participants. ' +
+ 'Run "echo $DEVGLIDE_PANE_ID" in your shell and pass the result as paneId. ' +
+ 'Do not use "auto" — the server cannot reliably guess your pane.',
+ );
+ }
+
+ if (getJoinInFlight()) {
+ return errorResult(
+ 'chat_join is already in progress for this MCP session. Wait for it to finish, or use a separate MCP session for another agent.',
+ );
+ }
+ setJoinInFlight(true);
+ try {
+ const sessionCheck = await ensureSessionCanJoin();
+ if (sessionCheck.ok === false) return sessionCheck.result;
+ const mcpSid = getMcpSessionId(server);
+ const joinHeaders = mcpSid ? { 'mcp-session-id': mcpSid } : undefined;
+ const res = await chatApi('/join', { name, model: model ?? null, paneId, submitKey: submitKey ?? undefined }, joinHeaders);
+ if (!res.ok) {
+ const data = res.data as { error?: string; diagnostics?: unknown };
+ const errMsg = data?.error ?? 'Join failed';
+ const diag = data?.diagnostics;
+ if (diag) {
+ return errorResult(`${errMsg}\n\nDiagnostics: ${JSON.stringify(diag, null, 2)}`);
+ }
+ return errorResult(errMsg);
+ }
+ // Use the resolved name from the server (may be a generated unique name)
+ const participant = res.data as { name: string; projectId?: string | null; paneId?: string | null };
+ setSessionEntry({ name: participant.name, projectId: participant.projectId ?? null, paneId: participant.paneId ?? paneId });
+ // Attach rules of engagement so the joining LLM knows how to behave
+ const rules = getEffectiveRules(participant.projectId);
+ return jsonResult({ ...participant, rules });
+ } finally {
+ setJoinInFlight(false);
+ }
+ },
+ );
+
+ // ── 2. chat_leave ─────────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_leave',
+ 'Leave the chat room. Uses the name from the current session.',
+ {
+ paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before leaving. Only needed when this MCP session has no tracked chat state.'),
+ },
+ async ({ paneId }) => {
+ if (getJoinInFlight()) {
+ return errorResult('chat_join is still in progress for this MCP session. Wait for it to finish before leaving.');
+ }
+ await tryAdoptSessionByPaneId(paneId);
+ const sessionEntry = getSessionEntry();
+ if (!sessionEntry) return errorResult('Not joined — call chat_join first');
+ const current = sessionEntry;
+ const res = await chatApi('/leave', { name: current.name, projectId: current.projectId });
+ if (!res.ok) {
+ if (res.status === 404) {
+ setSessionEntry(null);
+ return jsonResult({ ok: true, left: current.name, stale: true });
+ }
+ return errorResult((res.data as { error?: string })?.error ?? 'Leave failed');
+ }
+ setSessionEntry(null);
+ return jsonResult(res.data);
+ },
+ );
+
+ // ── 3. chat_send ──────────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_send',
+ 'Send a message to the chat room. Delivery goes to recipients resolved from the `to` param plus body @mentions; use @all to broadcast to all participants. LLM messages with no recipients in either field are persisted but not PTY-delivered. Messages that start with #pipe- or reference a currently running #pipe-* are rejected — use pipe_submit for pipe stage output.',
+ {
+ message: z.string().describe('Message text (markdown supported)'),
+ to: z.string().optional().describe('Recipient name for direct delivery. Merged with body @mentions to build the delivery target set. For LLM senders, leaving both empty means no PTY delivery.'),
+ paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before sending. Only needed when this MCP session has no tracked chat state.'),
+ },
+ async ({ message, to, paneId }) => {
+ const adopted = await tryAdoptSessionByPaneId(paneId);
+ const sessionName = adopted?.name ?? getSessionName();
+ const sessionProjectId = adopted?.projectId ?? getSessionProjectId();
+ if (!sessionName) return errorResult('Not joined — call chat_join first');
+ const res = await chatApi('/send', { from: sessionName, message, to, projectId: sessionProjectId });
+ if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Send failed');
+ return jsonResult(res.data);
+ },
+ );
+
+ // ── 3b. pipe_submit ─────────────────────────────────────────────────
+
+ server.tool(
+ 'pipe_submit',
+ 'Submit your output for a pipe stage. Use this instead of chat_send when responding to a #pipe- prompt. Optionally pass assignmentId for forward-compatible assignment binding.',
+ {
+ pipeId: z.string().describe('The pipe ID — accepts "#pipe-abc123", "pipe-abc123", or just "abc123"'),
+ content: z.string().describe('Your stage output content (markdown supported)'),
+ assignmentId: z.string().optional().describe('Optional assignment ID for forward-compatible assignment binding.'),
+ paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before submitting. Only needed when this MCP session has no tracked chat state.'),
+ },
+ async ({ pipeId, content, assignmentId, paneId }) => {
+ const adopted = await tryAdoptSessionByPaneId(paneId);
+ const sessionName = adopted?.name ?? getSessionName();
+ const sessionProjectId = adopted?.projectId ?? getSessionProjectId();
+ if (!sessionName) return errorResult('Not joined — call chat_join first');
+ // Normalize pipeId: strip leading "#pipe-" or "pipe-" prefix to get the bare ID
+ const normalizedPipeId = pipeId.replace(/^#?pipe-/i, '');
+ const res = await chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/submit`, {
+ from: sessionName,
+ content,
+ projectId: sessionProjectId,
+ });
+ if (!res.ok) {
+ const data = res.data as { error?: string; code?: string };
+ const msg = data.code
+ ? `[${data.code}] ${data.error ?? 'Pipe submit failed'}`
+ : (data.error ?? 'Pipe submit failed');
+ return errorResult(msg);
+ }
+ return jsonResult(res.data);
+ },
+ );
+
+ // ── 3c. pipe_read_output ───────────────────────────────────────────
+
+ server.tool(
+ 'pipe_read_output',
+ 'Read the stage input content you are entitled to for the current stage. Returns previous stage output (linear), original prompt (fan-out), or aggregated fan-out outputs (synthesizer). This is the content tool — use pipe_get_assignment for assignment metadata. Caller identity resolved from your chat session.',
+ {
+ pipeId: z.string().describe('The pipe ID — accepts "#pipe-abc123", "pipe-abc123", or just "abc123"'),
+ paneId: z.string().optional().describe('Optional pane ID to adopt an existing REST-joined participant into this MCP session before reading. Only needed when this MCP session has no tracked chat state.'),
+ },
+ async ({ pipeId, paneId }) => {
+ const adopted = await tryAdoptSessionByPaneId(paneId);
+ const sessionEntry = adopted ?? getSessionEntry();
+ if (!sessionEntry?.name) return errorResult('Not joined — call chat_join first');
+ const effectivePaneId = paneId ?? sessionEntry.paneId;
+ if (!effectivePaneId) return errorResult('No pane ID available — pass paneId or rejoin');
+ const normalizedPipeId = pipeId.replace(/^#?pipe-/i, '');
+ const query = sessionEntry.projectId ? `?projectId=${encodeURIComponent(sessionEntry.projectId)}` : '';
+ const res = await chatApi(
+ `/pipes/${encodeURIComponent(normalizedPipeId)}/output${query}`,
+ undefined,
+ { 'x-pane-id': effectivePaneId },
+ );
+ if (!res.ok) {
+ const data = res.data as { error?: string };
+ return errorResult(data?.error ?? 'Pipe read failed');
+ }
+ return jsonResult(res.data);
+ },
+ );
+
+
+ // ── 3d. pipe_list_assignments ──────────────────────────────────────
+
+ server.tool(
+ 'pipe_list_assignments',
+ 'List your active and pending pipe assignments with lease status and deadlines.',
+ { paneId: z.string().optional().describe('Optional pane ID to adopt session.') },
+ async ({ paneId }) => {
+ const adopted = await tryAdoptSessionByPaneId(paneId);
+ const sessionName = adopted?.name ?? getSessionName();
+ const sessionProjectId = adopted?.projectId ?? getSessionProjectId();
+ if (!sessionName) return errorResult('Not joined — call chat_join first');
+ const res = await chatApi(`/pipes/assignments?assignee=${encodeURIComponent(sessionName)}${sessionProjectId ? `&projectId=${encodeURIComponent(sessionProjectId)}` : ''}`);
+ if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Failed to list assignments');
+ return jsonResult(res.data);
+ },
+ );
+
+ // ── 3e. pipe_get_assignment ───────────────────────────────────────
+
+ server.tool(
+ 'pipe_get_assignment',
+ 'Inspect assignment metadata for a specific pipe (role, stage, lease status, deadline). This is the metadata tool — use pipe_read_output for stage input content.',
+ {
+ pipeId: z.string().describe('The pipe ID'),
+ paneId: z.string().optional().describe('Optional pane ID to adopt session.'),
+ },
+ async ({ pipeId, paneId }) => {
+ const adopted = await tryAdoptSessionByPaneId(paneId);
+ const sessionName = adopted?.name ?? getSessionName();
+ const sessionProjectId = adopted?.projectId ?? getSessionProjectId();
+ if (!sessionName) return errorResult('Not joined — call chat_join first');
+ const normalizedPipeId = pipeId.replace(/^#?pipe-/i, '');
+ const query = sessionProjectId ? `?projectId=${encodeURIComponent(sessionProjectId)}` : '';
+ const res = await chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/assignment${query}`, undefined, { 'x-pane-id': paneId ?? getSessionEntry()?.paneId ?? '' });
+ if (!res.ok) return errorResult((res.data as { error?: string })?.error ?? 'Failed to get assignment');
+ return jsonResult(res.data);
+ },
+ );
+
+ // ── 4. chat_read ──────────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_read',
+ 'Read recent chat message history. Returns all persisted messages regardless of PTY delivery — some messages may not have been injected into your terminal pane.',
+ {
+ limit: z.number().optional().describe('Max messages to return (default 50)'),
+ since: z.string().optional().describe('ISO timestamp — only return messages after this time'),
+ },
+ async ({ limit, since }) => {
+ const messages = store.readMessages({ limit, since }, getSessionProjectId());
+ return jsonResult(messages);
+ },
+ );
+
+ // ── 5. chat_members ───────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_members',
+ 'List active chat participants with their pane link status.',
+ {},
+ async () => {
+ // Use REST API for consistent behavior — direct registry calls can miss
+ // participants when sessionProjectId is null (before join or after restart).
+ const res = await chatApi('/members');
+ if (!res.ok) return errorResult('Failed to fetch members');
+ return jsonResult(res.data);
+ },
+ );
+
+// ── pipe_status ────────────────────────────────────────────────────── server.tool( 'pipe_status', 'Get detailed status of a pipe: slot states, active leases, timing breakdown, and dead-letter entries.', { pipeId: z.string().describe('The pipe ID'), paneId: z.string().optional().describe('Optional pane ID to adopt session'), }, async ({ pipeId, paneId }) => { await tryAdoptSessionByPaneId(paneId); const sessionEntry = getSessionEntry(); const pid = sessionEntry?.projectId ?? null; const normalizedPipeId = pipeId.replace(/^#?pipe-/i, ''); const query = pid ? `?projectId=${encodeURIComponent(pid)}` : ''; const [statusRes, timingRes] = await Promise.all([ chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/status${query}`).catch(() => null), chatApi(`/pipes/${encodeURIComponent(normalizedPipeId)}/timing${query}`).catch(() => null), ]); if (!statusRes?.ok) { const data = statusRes?.data as { error?: string } | undefined; return errorResult(data?.error ?? `Pipe #${normalizedPipeId} not found`); } const result: Record = { ...(statusRes.data as Record) }; if (timingRes?.ok) { const td = timingRes.data as Record; result.timing = { totalDurationMs: td.totalDurationMs, criticalPathMs: td.criticalPathMs, completedAt: td.completedAt, stages: td.stages }; } return jsonResult(result); }, );
+ // ── 6. chat_status ────────────────────────────────────────────────────
+
+ server.tool(
+ 'chat_status',
+ 'Check your current chat connection status and diagnostics. Use this to debug delivery issues or verify your session is healthy.',
+ {},
+ async () => {
+ const sessionEntry = getSessionEntry();
+ const pid = sessionEntry?.projectId ?? null;
+ const sessionName = sessionEntry?.name ?? null;
+ const joined = !!sessionName;
+
+ // Use REST API for consistent behavior — avoids project-scoping issues
+ // when sessionProjectId is null (before join or after restart).
+ const statusQuery = sessionName ? `?name=${encodeURIComponent(sessionName)}${pid ? `&projectId=${encodeURIComponent(pid)}` : ''}` : '';
+ const statusRes = await chatApi(`/status${statusQuery}`).catch(() => null);
+ if (statusRes?.ok) {
+ const data = statusRes.data as Record;
+ return jsonResult({ joined, name: sessionName, ...data });
+ }
+ if (statusRes?.status === 404 && sessionEntry) {
+ setSessionEntry(null);
+ return jsonResult({
+ joined: false,
+ name: null,
+ projectId: pid,
+ error: `Tracked participant "${sessionName}" is no longer registered.`,
+ });
+ }
+
+ // Fallback to basic info if REST fails
+ return jsonResult({
+ joined,
+ name: sessionName,
+ projectId: pid,
+ error: 'Could not fetch status from REST API',
+ });
+ },
+ );
+
+ return server;
+}
diff --git a/src/apps/chat/tsconfig.json b/src/apps/chat/tsconfig.json
new file mode 100644
index 0000000..ef860bf
--- /dev/null
+++ b/src/apps/chat/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../packages/tsconfig/node.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src", "mcp.ts", "types.ts", "services"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/src/apps/chat/types.ts b/src/apps/chat/types.ts
new file mode 100644
index 0000000..33bff3c
--- /dev/null
+++ b/src/apps/chat/types.ts
@@ -0,0 +1,226 @@
+export interface ChatMessage {
+ id: string;
+ ts: string; // ISO timestamp
+ from: string; // participant name
+ to: string | null; // null = no target, "name" = direct, "all" = broadcast, comma-separated for multi
+ body: string; // markdown text
+ type: 'message' | 'join' | 'leave' | 'system';
+ pipe?: PipeMessageMeta; // present when message is part of a pipe run
+ deliveredTo?: number; // count of participants who received PTY delivery
+ unresolvedTargets?: string[]; // @mention tokens that didn't match any known participant
+}
+
+/** Result of target resolution for PTY delivery. */
+export interface DeliveryPlan {
+ /** Raw @mention tokens as written (e.g. "all", "claude-7", "team-ui") — for msg.to storage */
+ targetLabels: string[];
+ /** Concrete participant names for PTY delivery (expanded from labels) */
+ recipients: string[];
+ /** Direct @mentions only (no group expansions) — for status side-effects */
+ concreteAssignees: string[];
+ /** Whether to fall back to broadcast when recipients is empty */
+ fallbackBroadcast: boolean;
+ /** Individual @mention tokens that didn't resolve to any known participant */
+ unresolvedTargets: string[];
+}
+
+// ── Pipe types ───────────────────────────────────────────────────────
+
+export type PipeMode = 'linear' | 'merge' | 'merge-all' | 'explain' | 'summarize';
+
+export type PipeTimeoutPolicy = 'fail' | 'reassign' | 'escalate';
+
+export type PipeRole =
+ | 'start'
+ | 'handoff'
+ | 'fan-out-request'
+ | 'stage-output'
+ | 'fan-out'
+ | 'synth-request'
+ | 'final'
+ | 'assignee-unavailable'
+ | 'failed'
+ | 'cancelled';
+
+export interface PipeMessageMeta {
+ pipeId: string;
+ mode: PipeMode;
+ role: PipeRole;
+ assignees?: string[]; // ordered list on 'start'; defines sequence (linear) or fan-out + synth (merge, last = synth)
+ prompt?: string; // original user prompt, carried on 'start' so reducer can reconstruct it
+ stage?: number; // 1-indexed, for linear handoff/stage-output
+ expectedAssignees?: string[]; // who the reducer expects responses from at current step
+ targetAssignee?: string; // who this system message is directed at (handoff, fan-out-request, synth-request)
+ reason?: 'left' | 'detached' | 'pane-closed' | 'cancelled-by-user' | 'timeout';
+}
+
+/** Derived pipe status — computed from log, not stored. */
+export type PipeStatus = 'running' | 'completed' | 'failed' | 'cancelled';
+
+export type PipeUiEventType =
+ | 'start'
+ | 'complete'
+ | 'failed'
+ | 'cancel'
+ | 'queued'
+ | 'instruction'
+ | 'stage-output';
+
+export interface PipeUiEvent {
+ id: string;
+ ts: string;
+ type: PipeUiEventType;
+ pipeId: string;
+ mode?: PipeMode | null;
+ actionType?: 'handoff' | 'fan-out-request' | 'synth-request';
+ assignee?: string;
+ from?: string;
+ role?: Extract;
+ stage?: number;
+ content?: string;
+ reason?: string;
+ // Recovery fields — present on 'start' events for state reconstruction
+ assignees?: string[];
+ prompt?: string;
+ stageTimeoutMs?: number;
+ timeoutPolicy?: PipeTimeoutPolicy;
+}
+
+export interface ChatParticipant {
+ name: string;
+ kind: 'user' | 'llm';
+ model: string | null; // e.g. "claude", "cursor", "codex"
+ status?: 'idle' | 'working';
+ paneId: string | null; // linked shell pane for PTY delivery
+ paneNum: number | null; // per-project display number — used by frontend for color assignment
+ projectId: string | null; // project this participant belongs to
+ submitKey: string; // character sent after delayed PTY injection to trigger submit (default \r, correct for all known clients)
+ joinedAt: string;
+ lastSeen: string;
+ detached: boolean; // true when MCP session closed but pane is still alive — awaiting reclaim
+ joinedVia?: 'rest' | 'mcp' | null; // how the participant joined — 'rest' for direct REST call, 'mcp' for MCP tool
+ clientId?: string; // optional stable identity for future strong-reclaim support
+ permissionMode?: 'supervised' | 'auto-accept' | 'unrestricted' | null; // permission mode the LLM was launched with
+}
+
+export interface ChatJoinResponse extends ChatParticipant {
+ rules: string; // effective rules of engagement (markdown)
+}
+
+// ── Assignment types ────────────────────────────────────────────────
+
+/** Lifecycle states for a durable assignment. */
+export type AssignmentStatus =
+ | 'assigned' // created, notification not yet sent
+ | 'notified' // compact notification delivered via PTY
+ | 'acknowledged' // assignee acknowledged receipt
+ | 'payload_fetched' // assignee fetched the authoritative payload
+ | 'submitted' // assignee submitted stage output
+ | 'expired' // deadline passed without submission
+ | 'reassigned' // replaced by a new assignment to a different agent
+ | 'superseded' // replaced by a retry of the same agent
+ | 'cancelled'; // pipe was cancelled, assignment voided
+
+/** Terminal statuses — an assignment in one of these states cannot transition further. */
+export const TERMINAL_ASSIGNMENT_STATUSES: ReadonlySet = new Set([
+ 'submitted', 'expired', 'reassigned', 'superseded', 'cancelled',
+]);
+
+/** Valid status transitions for the assignment state machine. */
+export const ASSIGNMENT_TRANSITIONS: Readonly> = {
+ assigned: ['notified', 'expired', 'reassigned', 'superseded', 'cancelled'],
+ notified: ['acknowledged', 'expired', 'reassigned', 'superseded', 'cancelled'],
+ acknowledged: ['payload_fetched', 'expired', 'reassigned', 'superseded', 'cancelled'],
+ payload_fetched: ['submitted', 'expired', 'reassigned', 'superseded', 'cancelled'],
+ submitted: [],
+ expired: [],
+ reassigned: [],
+ superseded: [],
+ cancelled: [],
+};
+
+// ── Payload types ───────────────────────────────────────────────────
+
+/** Lifecycle states for stored payloads. */
+export type PayloadStatus = 'active' | 'archived' | 'deleted';
+
+// —— Pipe observability types ———————————————————————————————————————————————
+
+export interface StageTiming {
+ stage?: number;
+ assignee: string;
+ role: Extract;
+ grantedAt: string | null;
+ submittedAt: string | null;
+ deadline: string | null;
+ durationMs: number | null;
+}
+
+export interface PipeTimingSummary {
+ pipeId: string;
+ mode: PipeMode;
+ status: PipeStatus;
+ createdAt: string;
+ completedAt: string | null;
+ totalDurationMs: number | null;
+ stages: StageTiming[];
+ criticalPathMs: number | null;
+ stageTimeoutMs: number;
+ timeoutPolicy: PipeTimeoutPolicy;
+}
+
+export interface RuntimeLeaseStatus {
+ pipeId: string;
+ assignee: string;
+ slotRole: string;
+ stage?: number;
+ grantedAt: string;
+ deadline: string | null;
+ elapsedMs: number;
+ remainingMs: number | null;
+ isOverdue: boolean;
+}
+
+export interface DeadLetterEntry {
+ pipeId: string;
+ assignee: string;
+ stage?: number;
+ role: string;
+ status: 'timeout-expired' | 'stuck' | 'delivery-failed';
+ reason: string;
+ grantedAt: string | null;
+ deadline: string | null;
+ elapsedMs: number;
+ pipeMode: PipeMode;
+ pipeStatus: PipeStatus;
+}
+
+// —— Pipe provenance types ————————————————————————————————————————————————
+
+export type ProvenanceEvent =
+ | 'created'
+ | 'stage-granted'
+ | 'stage-submitted'
+ | 'completed'
+ | 'failed'
+ | 'cancelled'
+ | 'payload-created'
+ | 'payload-fetched'
+ | 'assignment-created'
+ | 'assignment-transitioned'
+ | 'delivery-created'
+ | 'delivery-fetched'
+ | 'delivery-exhausted';
+
+export interface ProvenanceRecord {
+ ts: string;
+ pipeId: string;
+ event: ProvenanceEvent;
+ actor: string;
+ actorKind: 'user' | 'llm' | 'system';
+ stage?: number;
+ role?: PipeRole | Extract;
+ assignmentId?: string;
+ payloadId?: string;
+ metadata?: Record;
+}
diff --git a/src/apps/coder/package.json b/src/apps/coder/package.json
index 15e1e9b..16abaae 100644
--- a/src/apps/coder/package.json
+++ b/src/apps/coder/package.json
@@ -3,14 +3,5 @@
"version": "0.1.0",
"type": "module",
"description": "In-browser IDE for viewing and editing monorepo files",
- "main": "server.js",
- "scripts": {
- "dev": "node --watch server.js",
- "start": "node server.js",
- "lint": "eslint ."
- },
- "private": true,
- "dependencies": {
- "express": "^5.2.1"
- }
+ "private": true
}
diff --git a/src/apps/coder/public/favicon.svg b/src/apps/coder/public/favicon.svg
deleted file mode 100644
index 85b3c77..0000000
--- a/src/apps/coder/public/favicon.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/apps/coder/public/page.css b/src/apps/coder/public/page.css
index bfcbdb2..02736eb 100644
--- a/src/apps/coder/public/page.css
+++ b/src/apps/coder/public/page.css
@@ -1,34 +1,5 @@
-.page-coder {
- display: flex;
- flex-direction: column;
- height: 100%;
- font-family: var(--df-font-mono);
- background: var(--df-color-bg-base);
- color: var(--df-color-text-primary);
- -webkit-font-smoothing: antialiased;
- overflow: hidden;
-}
-
-/* ── Toolbar ────────────────────────────────────────────────────────── */
-.page-coder .coder-toolbar {
- display: flex;
- align-items: center;
- gap: var(--df-space-2);
- padding: 0 var(--df-space-4);
- height: 38px;
- background: var(--df-color-bg-surface);
- border-bottom: 1px solid var(--df-color-border-default);
- flex-shrink: 0;
-}
-
-.page-coder .app-name {
- font-size: var(--df-font-size-md);
- font-weight: normal;
- color: var(--df-color-accent-default);
- font-family: var(--df-font-mono);
- letter-spacing: var(--df-letter-spacing-wider);
- text-transform: uppercase;
-}
+/* ── Coder — App-specific styles ─────────────────────────────────────────────── */
+/* Common primitives (page root, header, scrollbar) are in shared-ui.css. */
.page-coder .save-status {
margin-left: auto;
diff --git a/src/apps/coder/public/page.js b/src/apps/coder/public/page.js
index ccd4260..0536e30 100644
--- a/src/apps/coder/public/page.js
+++ b/src/apps/coder/public/page.js
@@ -5,6 +5,11 @@
// in the SPA shell (no iframe).
import { escapeHtml } from '/shared-assets/ui-utils.js';
+import { createApi } from '/shared-ui/app-page.js';
+import { createHeader } from '/shared-ui/components/header.js';
+import { confirmModal } from '/shared-ui/components/modal.js';
+
+const api = createApi('coder');
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2';
@@ -33,10 +38,7 @@ let _monacoReady = false;
// ── HTML ─────────────────────────────────────────────────────────────
const BODY_HTML = `
-
- Coder
-
-
+ ${createHeader({ brand: 'Coder', meta: ' ' })}
-
-
-
-
-
- Cancel
- Close Anyway
-
-
-
`;
// ── Monaco loader ───────────────────────────────────────────────────
@@ -192,38 +181,6 @@ function updateTreeHeader() {
if (header) header.textContent = _currentRoot ? _currentRoot.split('/').pop() : 'Explorer';
}
-function coderConfirm(title, message) {
- return new Promise(resolve => {
- const overlay = _container?.querySelector('.modal-overlay');
- if (!overlay) { resolve(false); return; }
-
- const titleEl = overlay.querySelector('#coder-confirm-title');
- const msgEl = overlay.querySelector('#coder-confirm-msg');
- if (titleEl) titleEl.textContent = title;
- if (msgEl) msgEl.textContent = message;
-
- overlay.classList.remove('hidden');
-
- const ac = new AbortController();
- const close = (value) => {
- overlay.classList.add('hidden');
- ac.abort();
- resolve(value);
- };
-
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) close(false);
- }, { signal: ac.signal });
-
- overlay.querySelector('[data-action="confirm-cancel"]')?.addEventListener('click', () => close(false), { signal: ac.signal });
- overlay.querySelector('[data-action="confirm-ok"]')?.addEventListener('click', () => close(true), { signal: ac.signal });
-
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') close(false);
- }, { signal: ac.signal });
- });
-}
-
// ── File tree ───────────────────────────────────────────────────────
async function fetchTree() {
@@ -233,9 +190,9 @@ async function fetchTree() {
if (!tree) return;
try {
const url = _currentRoot
- ? `/api/coder/tree?root=${encodeURIComponent(_currentRoot)}`
- : '/api/coder/tree';
- const res = await fetch(url);
+ ? `/tree?root=${encodeURIComponent(_currentRoot)}`
+ : '/tree';
+ const res = await api(url);
const nodes = await res.json();
if (gen !== _treeGen) return;
tree.innerHTML = '';
@@ -295,7 +252,7 @@ async function openFile(path) {
if (!_monacoReady || !_editor) return;
try {
const rootParam = _currentRoot ? `&root=${encodeURIComponent(_currentRoot)}` : '';
- const res = await fetch(`/api/coder/file?path=${encodeURIComponent(path)}${rootParam}`);
+ const res = await api(`/file?path=${encodeURIComponent(path)}${rootParam}`);
if (!res.ok) { setStatus((await res.json()).error, 'err'); return; }
const { content } = await res.json();
const model = monaco.editor.createModel(content, langFromPath(path));
@@ -341,7 +298,7 @@ async function closeTab(path) {
if (!_container) return;
const tab = _tabs.get(path);
if (tab?.dirty) {
- const ok = await coderConfirm('Unsaved Changes', `Unsaved changes in ${path.split('/').pop()}. Close anyway?`);
+ const ok = await confirmModal(_container, { title: 'Unsaved Changes', message: `Unsaved changes in ${path.split('/').pop()}. Close anyway?`, confirmLabel: 'Close Anyway', confirmCls: 'btn-danger' });
if (!ok) return;
}
tab?.model?.dispose();
@@ -379,7 +336,7 @@ async function saveActive() {
const content = tab.model.getValue();
setStatus('Saving\u2026', '');
try {
- const res = await fetch('/api/coder/file', {
+ const res = await api('/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: _activeFile, content, root: _currentRoot }),
@@ -419,7 +376,7 @@ export async function mount(container, ctx) {
_treeGen = 0;
// 1. Scope the container
- container.classList.add('page-coder');
+ container.classList.add('page-coder', 'app-page');
// 2. Build HTML
container.innerHTML = BODY_HTML;
@@ -506,7 +463,7 @@ export function unmount(container) {
}
// 5. Remove scope class & clear HTML
- container.classList.remove('page-coder');
+ container.classList.remove('page-coder', 'app-page');
container.innerHTML = '';
// 6. Clear module references
diff --git a/src/apps/coder/server.js b/src/apps/coder/server.js
deleted file mode 100644
index ccfda53..0000000
--- a/src/apps/coder/server.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// ── Coder — standalone server (superseded by unified server) ─────────────────
-// This file is retained for reference. All coder routes are now served by
-// src/server.ts via src/routers/coder.ts. Use `devglide dev` to run.
diff --git a/src/apps/documentation/package.json b/src/apps/documentation/package.json
new file mode 100644
index 0000000..c4eb431
--- /dev/null
+++ b/src/apps/documentation/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@devglide/documentation",
+ "version": "0.1.0",
+ "description": "Operational guidance for DevGlide tools — workflows, troubleshooting, examples",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint ."
+ },
+ "private": true,
+ "dependencies": {
+ "@devglide/mcp-utils": "workspace:*",
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "zod": "^3.25.49"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.4",
+ "typescript": "^5.8.0"
+ }
+}
diff --git a/src/apps/documentation/seed/example-flaky-selector.json b/src/apps/documentation/seed/example-flaky-selector.json
new file mode 100644
index 0000000..7bfbe3f
--- /dev/null
+++ b/src/apps/documentation/seed/example-flaky-selector.json
@@ -0,0 +1,37 @@
+{
+ "id": "ex-flaky-selector",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Flaky selector in React controlled form",
+ "startingAssumptions": [
+ "A test scenario intermittently fails on a click or type step targeting a form element.",
+ "The selector works when tested manually in the browser DevTools.",
+ "The app uses React with controlled form inputs."
+ ],
+ "toolSequence": [
+ "test_get_result — read the failure details. Note which step failed and the selector used.",
+ "Inspect the app source code to check if the element has stable attributes (id, data-testid, role, aria-label).",
+ "If the selector uses dynamic class names (CSS modules, styled-components): it will break between builds.",
+ "If the element is rendered conditionally or inside a transition: add a wait-for-element step before the interaction.",
+ "Add a data-testid attribute to the element in the app source code if no stable selector exists.",
+ "Update the scenario to use the data-testid selector and add a wait step.",
+ "Re-run the scenario multiple times to verify the fix is stable."
+ ],
+ "whatGoodLooksLike": [
+ "The scenario passes consistently across multiple runs.",
+ "The selector uses a stable attribute that will not change between builds.",
+ "The wait step ensures the element is rendered before interaction."
+ ],
+ "whatBadLooksLike": [
+ "The scenario still fails intermittently — the timing issue is not fully resolved.",
+ "Adding data-testid changes app behavior (unlikely but possible if the attribute conflicts)."
+ ],
+ "whatToDoNext": [
+ "If still flaky: increase the wait timeout or add an assertion that the element is visible before interacting.",
+ "If the form field is a complex component (date picker, autocomplete): interact via its UI controls rather than targeting the underlying input directly.",
+ "Consider whether the React component needs a fix for accessibility — stable selectors often align with proper ARIA attributes."
+ ],
+ "tags": ["example", "test", "selector", "react", "form", "flaky"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/example-no-result-recovery.json b/src/apps/documentation/seed/example-no-result-recovery.json
new file mode 100644
index 0000000..870e0ee
--- /dev/null
+++ b/src/apps/documentation/seed/example-no-result-recovery.json
@@ -0,0 +1,35 @@
+{
+ "id": "ex-no-result-recovery",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Scenario returns no result found — recovery steps",
+ "startingAssumptions": [
+ "You submitted a scenario via test_run_scenario.",
+ "test_get_result returns 'no result found' after waiting."
+ ],
+ "toolSequence": [
+ "test_get_result — confirm the 'no result found' response.",
+ "log_read — check if devtools.js is posting any log entries. If no recent entries, devtools.js is not active.",
+ "shell_run_command to check if the dev server process is running.",
+ "If the dev server is running: the issue is the browser. Ask the user to verify the browser tab is open on the app.",
+ "If devtools.js is not in the page source: instruct the user to add the devtools.js script tag.",
+ "After the browser is confirmed ready: re-submit the scenario via test_run_scenario.",
+ "test_get_result — should now return a real result."
+ ],
+ "whatGoodLooksLike": [
+ "After recovery, test_get_result returns 'passed' or 'failed' (not 'no result found').",
+ "log_read shows fresh entries from the current browser session."
+ ],
+ "whatBadLooksLike": [
+ "test_get_result still returns 'no result found' after recovery steps.",
+ "log_read shows no entries — devtools.js is still not connected."
+ ],
+ "whatToDoNext": [
+ "If still no result: check that the DevGlide server port matches what devtools.js is configured for.",
+ "Check the browser console directly for devtools.js initialization errors.",
+ "As a last resort, fully close and reopen the browser, navigate to the app, and retry."
+ ],
+ "tags": ["example", "test", "no-result", "recovery"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/example-pass-with-errors.json b/src/apps/documentation/seed/example-pass-with-errors.json
new file mode 100644
index 0000000..c22d58f
--- /dev/null
+++ b/src/apps/documentation/seed/example-pass-with-errors.json
@@ -0,0 +1,36 @@
+{
+ "id": "ex-pass-with-errors",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Scenario passes but logs show runtime exception",
+ "startingAssumptions": [
+ "A test scenario just completed with status 'passed'.",
+ "You are about to check logs as part of the verification workflow."
+ ],
+ "toolSequence": [
+ "test_get_result — confirms 'passed'.",
+ "log_read with level filter 'error' — read error-level log entries.",
+ "Identify the error: read the message, stack trace, and timestamp.",
+ "Correlate the error timestamp with the scenario steps to find the trigger.",
+ "Determine if the error is in app code (fixable) or third-party (document as noise).",
+ "If fixable: fix the app code, re-run the scenario, and re-check logs.",
+ "If third-party noise: document it in a project override via docs_add."
+ ],
+ "whatGoodLooksLike": [
+ "After the fix, the scenario still passes AND logs are clean of unexpected errors.",
+ "If the error was third-party noise: a project override documents it for future runs."
+ ],
+ "whatBadLooksLike": [
+ "The fix breaks the scenario — it now fails.",
+ "New errors appear after the fix.",
+ "The error is intermittent and hard to reproduce."
+ ],
+ "whatToDoNext": [
+ "If the fix broke the scenario: the fix may be incorrect. Revert and investigate further.",
+ "If new errors appeared: the fix had side effects. Review the change carefully.",
+ "If intermittent: run the scenario multiple times and check logs each time to gather more data."
+ ],
+ "tags": ["example", "test", "log", "runtime-error", "false-positive"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/example-test-ui-flow.json b/src/apps/documentation/seed/example-test-ui-flow.json
new file mode 100644
index 0000000..179bbff
--- /dev/null
+++ b/src/apps/documentation/seed/example-test-ui-flow.json
@@ -0,0 +1,40 @@
+{
+ "id": "ex-test-ui-flow",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Test a UI flow and inspect logs after run",
+ "startingAssumptions": [
+ "The app dev server is running.",
+ "A browser page is open on the app with devtools.js loaded.",
+ "The DevGlide server is running on port 7000.",
+ "You know which UI flow to test (e.g. 'create a new item', 'submit a form')."
+ ],
+ "toolSequence": [
+ "test_list_saved — check if a relevant saved scenario exists.",
+ "test_run_scenario with a natural language description of the flow (e.g. 'Navigate to the form page, fill in the name field with \"Test Club\", select a category, and click Submit. Verify a success message appears.')",
+ "Wait 3-5 seconds for the browser to consume and execute the scenario.",
+ "test_get_result — read the scenario outcome.",
+ "log_read with targetPath for the project log — read the browser console output after the run.",
+ "Assess: scenario passed + no unexpected errors in logs = verification success."
+ ],
+ "whatGoodLooksLike": [
+ "test_get_result returns status 'passed' with all steps completed.",
+ "log_read shows normal app output — no errors, only expected info/debug messages.",
+ "The UI reflects the expected state after the flow (e.g. the new item appears in a list)."
+ ],
+ "whatBadLooksLike": [
+ "test_get_result returns 'no result found' — the browser did not consume the scenario.",
+ "test_get_result returns status 'failed' with a step failure.",
+ "log_read shows error-level entries (unhandled exceptions, assertion failures).",
+ "test_get_result returns 'passed' but logs contain runtime errors."
+ ],
+ "whatToDoNext": [
+ "If 'no result found': verify browser is open with devtools.js, then retry.",
+ "If a step failed: read the failure details, check the selector and timing, and fix the scenario or app code.",
+ "If logs show errors: diagnose the root cause from the error message and stack trace, fix the app, and re-run.",
+ "If the scenario passed but logs have errors: treat as failure — add assertions or fix the underlying error."
+ ],
+ "tags": ["example", "test", "log", "verification", "ui"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/tool-guide-devglide-log.json b/src/apps/documentation/seed/tool-guide-devglide-log.json
new file mode 100644
index 0000000..ad1a6a1
--- /dev/null
+++ b/src/apps/documentation/seed/tool-guide-devglide-log.json
@@ -0,0 +1,48 @@
+{
+ "id": "guide-devglide-log",
+ "type": "tool-guide",
+ "toolName": "devglide-log",
+ "summary": "Read and manage structured log files captured from browser console output and server-side processes. Used alongside devglide-test to verify that apps run without runtime errors.",
+ "executionModel": "File-based log capture. The browser-side sniffer (devtools.js) intercepts console.log/warn/error calls and POSTs them to the DevGlide server, which appends them to a log file. Server-side logs are captured separately. log_read returns recent entries from a target log file.",
+ "prerequisites": [
+ "devtools.js must be loaded in the browser page for browser-side log capture.",
+ "The DevGlide server must be running to receive log POST requests.",
+ "A log session must be active — devtools.js creates a session on page load."
+ ],
+ "inputsExplained": {
+ "targetPath": "Path to the log file to read. For project-scoped logs, this is typically ~/.devglide/projects/{projectId}/logs/{project-name}-console.log. If omitted, reads the default DevGlide console log.",
+ "lines": "Number of recent lines to return. Defaults to 50.",
+ "level": "Optional filter by log level (log, warn, error, info, debug)."
+ },
+ "resultSemantics": {
+ "entries_returned": "Log entries matching the query. Each entry includes timestamp, level, source, and message.",
+ "empty_result": "No log entries found. This may mean: no logs captured yet, wrong targetPath, or devtools.js not loaded.",
+ "error_entries": "Entries with level 'error' indicate runtime exceptions or failed assertions in the app."
+ },
+ "preferredPatterns": [
+ "Always read logs after every devglide-test scenario run — log review is mandatory for verification, not optional.",
+ "To find the correct log path for a project: the pattern is ~/.devglide/projects/{projectId}/logs/{project-name}-console.log where project-name is the name registered in DevGlide.",
+ "Filter by level 'error' first to quickly identify runtime failures.",
+ "Distinguish expected noise from real failures. Common expected noise includes: map tile 404s, font loading warnings, style sheet 404s from optional dependencies, and development-mode React warnings.",
+ "When a scenario passes but logs show errors, treat the errors as failures — a passing scenario does not mean the app is healthy."
+ ],
+ "antiPatterns": [
+ "Do not skip log review after test runs. A scenario can pass while the app throws unhandled exceptions.",
+ "Do not assume an empty log means success — it may mean devtools.js is not capturing.",
+ "Do not treat all warnings as failures. Many frameworks emit development-mode warnings that are not bugs.",
+ "Do not read logs from a stale session. Check the session timestamp to ensure you are reading current output."
+ ],
+ "followUpChecks": [
+ "After identifying errors in logs, correlate them with the scenario step that was executing at that timestamp.",
+ "If logs show errors but the scenario passed, the error may be in a background process or async operation not covered by the scenario steps."
+ ],
+ "commonFailures": [
+ "No log entries despite running the app — devtools.js not loaded or posting to wrong server URL.",
+ "Log file path does not exist — project not registered in DevGlide or using wrong project name.",
+ "Logs show hundreds of entries — filter by level or use lines parameter to limit output."
+ ],
+ "seeAlso": ["devglide-test"],
+ "tags": ["log", "debugging", "verification", "console"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/tool-guide-devglide-test.json b/src/apps/documentation/seed/tool-guide-devglide-test.json
new file mode 100644
index 0000000..5ee50f8
--- /dev/null
+++ b/src/apps/documentation/seed/tool-guide-devglide-test.json
@@ -0,0 +1,54 @@
+{
+ "id": "guide-devglide-test",
+ "type": "tool-guide",
+ "toolName": "devglide-test",
+ "summary": "Run browser automation scenarios against an app instrumented with devtools.js. Scenarios are described in natural language, translated into browser commands, and executed inside a real browser page.",
+ "executionModel": "Browser-driven, not server-driven. Submitting a scenario via test_run_scenario or test_run_saved only queues work on the server. Actual execution happens inside the browser page where devtools.js is loaded — it polls the server for pending scenarios and runs them in-page. The server never drives the browser directly.",
+ "prerequisites": [
+ "The app's dev server must be running and reachable at its expected URL.",
+ "A real browser page must be open on the app (not just a server process).",
+ "devtools.js must be loaded in the page — add to the app's HTML in development.",
+ "The browser session must be active — devtools.js polls the DevGlide server; if the tab is closed or navigated away, polling stops.",
+ "The DevGlide server must be running (default port 7000)."
+ ],
+ "inputsExplained": {
+ "scenario": "A natural-language description of what to test. DevGlide translates this into a sequence of browser commands (navigate, click, type, assert, wait, etc.).",
+ "target": "The base URL of the app under test. Resolved from the active project if not specified.",
+ "steps": "When using test_run_scenario with explicit steps, each step is a command object (e.g. { command: 'click', selector: '#submit' })."
+ },
+ "resultSemantics": {
+ "passed": "All steps completed successfully. The scenario ran to completion without assertion failures.",
+ "failed": "One or more steps failed. Inspect the failed step details and check devglide-log for runtime errors.",
+ "no_result_found": "The browser has NOT consumed the scenario yet. This is NOT a test failure — it means devtools.js has not polled or the page is not open. Wait briefly and retry test_get_result, or verify the browser is open with devtools.js loaded."
+ },
+ "preferredPatterns": [
+ "Use click-based navigation after the initial page load. Simulate what a real user would do — click links, buttons, and menu items rather than navigating via URL.",
+ "Wait for state changes, not fixed timeouts. Use assertions or wait-for-element steps rather than arbitrary sleep durations.",
+ "Keep scenarios focused on one user flow. A scenario that tests club creation should not also test user settings.",
+ "Inspect devglide-log after every run — even if the scenario passes, the logs may contain runtime errors or unexpected warnings.",
+ "For stateful React forms with controlled inputs, add targeted data-testid attributes only where semantic selectors (role, label, placeholder) are unstable.",
+ "Prefer realistic data in scenarios — use plausible names, emails, and values rather than 'test123'."
+ ],
+ "antiPatterns": [
+ "Do not treat 'no result found' as a normal test failure. It means the scenario was not consumed by the browser.",
+ "Do not use excessive navigate steps. Each full navigation can reset React state, unmount components, and break stateful flows.",
+ "Do not rely only on scenario pass/fail without reviewing logs. A passing scenario can mask runtime errors visible in the console.",
+ "Do not use fragile CSS selectors like nth-child or deeply nested class chains. Prefer data-testid, role, or label-based selectors.",
+ "Do not run scenarios against a page that has not finished loading. Wait for the app to be interactive before submitting."
+ ],
+ "followUpChecks": [
+ "Read the browser log via devglide-log after every scenario run.",
+ "Distinguish expected noise (e.g. map tile 404s, style loading warnings) from real failures (unhandled exceptions, assertion errors).",
+ "If the scenario fails, check both the step failure details AND the logs — the root cause is often visible in the console before the step that failed."
+ ],
+ "commonFailures": [
+ "Scenario queued but never runs — browser tab closed, devtools.js not loaded, or wrong target URL.",
+ "Selector works in DevTools but automation fails — element not yet rendered, inside shadow DOM, or hidden behind an overlay.",
+ "React controlled input does not update — automation types text but React state does not reflect it. Use dispatchEvent with InputEvent or interact via the React fiber.",
+ "Navigate causes full reload and context loss — stateful app loses form data mid-flow. Use click navigation instead."
+ ],
+ "seeAlso": ["devglide-log"],
+ "tags": ["test", "browser", "automation", "devtools"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-controlled-input.json b/src/apps/documentation/seed/troubleshoot-controlled-input.json
new file mode 100644
index 0000000..2b08fdd
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-controlled-input.json
@@ -0,0 +1,27 @@
+{
+ "id": "ts-controlled-input",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "React controlled input does not update as expected",
+ "likelyCauses": [
+ "The automation sets the input value directly (e.g. element.value = 'text') but React does not recognize the change because no synthetic event was fired.",
+ "React controlled inputs require an InputEvent or Change event dispatched through the React event system to trigger state updates.",
+ "The input has a debounce or validation handler that delays or rejects the change."
+ ],
+ "howToDiagnose": [
+ "Check if the input is controlled (has a value prop bound to React state).",
+ "After the type step, check whether the displayed value matches what was typed.",
+ "Look at React DevTools to see if the component state updated.",
+ "Check the browser console for React warnings about uncontrolled-to-controlled transitions."
+ ],
+ "howToFix": [
+ "Use the type command which simulates individual key presses — this usually fires the correct events for React.",
+ "If type does not work, the scenario may need to dispatch a native InputEvent: new InputEvent('input', { bubbles: true, data: 'text' }).",
+ "For complex form fields (date pickers, rich text editors), interact through their UI controls rather than setting values directly.",
+ "Add a data-testid to the input and verify the value via assertion after typing."
+ ],
+ "whenToRetry": "After switching to the type command or implementing proper event dispatch.",
+ "tags": ["test", "react", "input", "controlled", "form"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-expected-warnings.json b/src/apps/documentation/seed/troubleshoot-expected-warnings.json
new file mode 100644
index 0000000..d2b8c64
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-expected-warnings.json
@@ -0,0 +1,27 @@
+{
+ "id": "ts-expected-warnings",
+ "type": "troubleshooting",
+ "toolName": "devglide-log",
+ "symptom": "logs contain expected app-specific warnings",
+ "likelyCauses": [
+ "The app has known non-critical warnings that appear during normal operation.",
+ "Common examples: map tile 404s when no local tileserver is running, font loading failures from CDN, stylesheet 404s from optional dependencies, React development-mode warnings.",
+ "These are not bugs — they are expected noise for the current development environment."
+ ],
+ "howToDiagnose": [
+ "Check the log level — warnings (level: warn) are typically non-critical.",
+ "Check if the warning message matches known patterns for the app (e.g. 'Failed to load tile', '404 for /fonts/', 'React does not recognize the X prop').",
+ "Check project documentation or overrides for a list of known expected warnings.",
+ "If unsure, ask the user whether the warning is expected for their app."
+ ],
+ "howToFix": [
+ "Do not treat expected warnings as failures in verification.",
+ "Document known expected warnings in a project override so other agents can distinguish them from real failures.",
+ "If the warning is unexpected: investigate whether a dependency is missing or misconfigured.",
+ "Filter log output by level 'error' to focus on real failures."
+ ],
+ "whenToRetry": "Not applicable — this is about correct interpretation, not a fixable failure.",
+ "tags": ["log", "warnings", "noise", "expected"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-navigate-reload.json b/src/apps/documentation/seed/troubleshoot-navigate-reload.json
new file mode 100644
index 0000000..c6839d3
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-navigate-reload.json
@@ -0,0 +1,27 @@
+{
+ "id": "ts-navigate-reload",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "navigate causes reload and context loss",
+ "likelyCauses": [
+ "The scenario uses a navigate step that triggers a full page reload instead of client-side routing.",
+ "The app is a single-page app (SPA) but the navigate step goes to a different origin or uses a full URL that bypasses the client router.",
+ "React or framework state (form data, auth tokens, component state) is lost on full reload.",
+ "devtools.js must re-initialize after a full page reload, causing a gap in the polling loop."
+ ],
+ "howToDiagnose": [
+ "Check the scenario steps — is there a navigate command mid-flow?",
+ "Check if the app uses client-side routing (React Router, Next.js, etc.).",
+ "Look at the browser network tab to confirm whether a full page load occurred."
+ ],
+ "howToFix": [
+ "Replace navigate steps with click-based navigation. Click the link, button, or menu item that the user would click to reach that page.",
+ "If a navigate is necessary (e.g. initial page load), keep it as the first step only.",
+ "For SPAs, ensure the navigate URL uses the same origin and lets the client router handle routing.",
+ "If form state is lost: restructure the scenario to complete one form before navigating away."
+ ],
+ "whenToRetry": "After rewriting the scenario to use click-based navigation instead of navigate steps.",
+ "tags": ["test", "navigate", "reload", "spa", "state-loss"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-no-result-found.json b/src/apps/documentation/seed/troubleshoot-no-result-found.json
new file mode 100644
index 0000000..7b35c64
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-no-result-found.json
@@ -0,0 +1,29 @@
+{
+ "id": "ts-no-result-found",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "test_get_result returns no result found",
+ "likelyCauses": [
+ "The browser page is not open or the tab is inactive.",
+ "devtools.js is not loaded in the page.",
+ "The browser is open on a different URL than the target app.",
+ "devtools.js polling was interrupted (page navigated away, tab crashed, or JavaScript error blocked polling).",
+ "The DevGlide server restarted after the scenario was submitted but before the browser consumed it."
+ ],
+ "howToDiagnose": [
+ "Check that a browser page is open on the target app URL.",
+ "Open the browser DevTools console and look for 'devglide' messages confirming devtools.js is active.",
+ "Check devglide-log for recent session entries — if there are none, devtools.js is not connected.",
+ "Verify the DevGlide server is running on the expected port (default 7000)."
+ ],
+ "howToFix": [
+ "Open the app in a browser if no page is open.",
+ "Add to the app's development HTML if devtools.js is missing.",
+ "Refresh the browser page to restart the devtools.js polling loop.",
+ "Re-submit the scenario after confirming the browser is ready."
+ ],
+ "whenToRetry": "After confirming the browser page is open and devtools.js is loaded. Wait 2-5 seconds after the fix, then call test_get_result again.",
+ "tags": ["test", "no-result", "browser", "devtools"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-pass-but-errors.json b/src/apps/documentation/seed/troubleshoot-pass-but-errors.json
new file mode 100644
index 0000000..44b8396
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-pass-but-errors.json
@@ -0,0 +1,28 @@
+{
+ "id": "ts-pass-but-errors",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "scenario passes but console logs show runtime error",
+ "likelyCauses": [
+ "The runtime error occurs in async code (setTimeout, Promise, event handler) that is not part of the synchronous scenario execution flow.",
+ "The error occurs in a background component or service worker, not in the component being tested.",
+ "The scenario assertions do not check for error states — they only verify the happy path.",
+ "A race condition causes the error only sometimes, depending on timing."
+ ],
+ "howToDiagnose": [
+ "Read the full log output after the scenario run. Filter by level 'error'.",
+ "Correlate the error timestamp with the scenario steps to identify which action triggered it.",
+ "Check if the error is in app code or a third-party library.",
+ "Run the scenario multiple times to check if the error is consistent or intermittent."
+ ],
+ "howToFix": [
+ "Treat the runtime error as a real failure even though the scenario passed — the scenario assertions were incomplete.",
+ "Fix the runtime error in the app code.",
+ "Add assertion steps to the scenario that verify no error state is visible (e.g. check that no error toast or banner appeared).",
+ "If the error is in a third-party library and cannot be fixed: document it as expected noise in a project override."
+ ],
+ "whenToRetry": "After fixing the runtime error. Re-run the scenario and verify that both the scenario passes AND logs are clean.",
+ "tags": ["test", "log", "runtime-error", "false-positive"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json b/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json
new file mode 100644
index 0000000..9b130f8
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-scenario-never-runs.json
@@ -0,0 +1,29 @@
+{
+ "id": "ts-scenario-never-runs",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "scenario accepted but never runs",
+ "likelyCauses": [
+ "devtools.js is loaded but polling is blocked by a JavaScript error in the app.",
+ "The app page is open but on a route that does not load devtools.js (e.g. a separate login page or error page).",
+ "A previous scenario is still running or stuck, blocking the queue.",
+ "The target URL in the scenario does not match the page currently open in the browser.",
+ "Browser DevTools is paused on a breakpoint, blocking script execution."
+ ],
+ "howToDiagnose": [
+ "Check the browser console for JavaScript errors that may have halted devtools.js.",
+ "Verify the page URL matches the expected target for the scenario.",
+ "Check if a previous scenario result is pending via test_get_result.",
+ "Look for 'devglide runner' messages in the browser console confirming the polling loop is active."
+ ],
+ "howToFix": [
+ "If a JS error is blocking devtools.js: fix the error or refresh the page.",
+ "If on the wrong page: navigate to the correct app URL.",
+ "If a previous scenario is stuck: refresh the page to clear the queue.",
+ "If DevTools is paused: resume execution."
+ ],
+ "whenToRetry": "After clearing the blocking condition. Re-submit the scenario — do not assume the old submission will eventually run.",
+ "tags": ["test", "scenario", "stuck", "polling"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/troubleshoot-selector-fails.json b/src/apps/documentation/seed/troubleshoot-selector-fails.json
new file mode 100644
index 0000000..4a326a2
--- /dev/null
+++ b/src/apps/documentation/seed/troubleshoot-selector-fails.json
@@ -0,0 +1,30 @@
+{
+ "id": "ts-selector-fails",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "selector works manually but automation fails",
+ "likelyCauses": [
+ "The element is not yet rendered when the automation tries to find it (timing issue).",
+ "The element is inside a shadow DOM and the selector does not pierce it.",
+ "The element is hidden behind a modal, overlay, or loading spinner.",
+ "The selector relies on dynamically generated class names (e.g. CSS modules, styled-components) that change between builds.",
+ "The element is in an iframe that the automation does not target."
+ ],
+ "howToDiagnose": [
+ "Add a wait-for-element step before the interaction step.",
+ "Check if the element is visible in the DOM at the time of the step (not hidden by CSS or not yet mounted).",
+ "Inspect whether the element is inside a shadow DOM boundary.",
+ "Check if the class names in the selector are stable across builds."
+ ],
+ "howToFix": [
+ "Add a wait step before interacting with the element.",
+ "Use stable selectors: data-testid, role attributes, aria-label, or visible text content.",
+ "If the element is behind a modal: add a step to dismiss the modal first.",
+ "If class names are dynamic: add a data-testid attribute to the element in the app source code.",
+ "If inside shadow DOM: use the appropriate shadow DOM piercing selector or restructure the test to avoid it."
+ ],
+ "whenToRetry": "After updating the selector or adding appropriate wait steps.",
+ "tags": ["test", "selector", "timing", "dom"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/seed/workflow-verify-ui-flow.json b/src/apps/documentation/seed/workflow-verify-ui-flow.json
new file mode 100644
index 0000000..914ea10
--- /dev/null
+++ b/src/apps/documentation/seed/workflow-verify-ui-flow.json
@@ -0,0 +1,56 @@
+{
+ "id": "workflow-verify-ui-flow",
+ "type": "workflow",
+ "name": "verify-ui-flow-with-devglide-test-and-devglide-log",
+ "goal": "Verify a UI flow end-to-end using browser test scenarios and log review. This is the standard verification loop for confirming that an app feature works correctly.",
+ "toolsInvolved": ["devglide-test", "devglide-log"],
+ "preflight": [
+ "Confirm the target app and its expected URL (check project config or ask the user).",
+ "Confirm the app's dev server is running. If not, start it via devglide-shell.",
+ "Confirm a browser page is open on the app with devtools.js loaded.",
+ "Confirm the browser session is active by checking devglide-log for recent session entries."
+ ],
+ "stepSequence": [
+ "Identify the UI flow to verify (e.g. 'create a club', 'submit a form', 'navigate to settings').",
+ "Check if a saved test scenario already exists via test_list_saved. Reuse if available.",
+ "If no saved scenario exists, create a realistic scenario describing the user flow in natural language. Use click-based navigation, realistic data, and targeted assertions.",
+ "Run the scenario via test_run_saved or test_run_scenario.",
+ "Wait briefly (2-5 seconds) then check the result via test_get_result.",
+ "If test_get_result returns 'no result found', the browser has not consumed the scenario yet. Verify the browser is open and devtools.js is loaded. Retry test_get_result after a few seconds.",
+ "Read browser logs via log_read immediately after the run — filter for errors first.",
+ "Separate expected noise from true failures. Expected noise includes: map tile 404s, font/style loading warnings, React development warnings.",
+ "If the scenario failed: examine the failed step, correlate with log entries at that timestamp, and diagnose the root cause.",
+ "If the scenario passed but logs show runtime errors: treat as a failure. The error may be in async code not covered by scenario assertions.",
+ "Fix the identified issue in the application code.",
+ "Re-run the scenario and re-check logs. Repeat until the scenario passes AND logs are clean of unexpected errors.",
+ "Report the verification result: pass/fail, what was tested, any fixes applied, and any known non-blocking warnings."
+ ],
+ "successCriteria": [
+ "The test scenario passes — all steps complete without assertion failures.",
+ "Browser logs contain no unexpected errors after the run.",
+ "Any app-specific expected noise is identified and excluded from failure assessment.",
+ "The verification result is reported clearly."
+ ],
+ "failureBranches": [
+ "If devtools.js is not loaded: guide the user to add the script tag to their app's development HTML.",
+ "If the dev server is not running: start it via shell_run_command.",
+ "If 'no result found' persists after multiple retries: check that the browser tab is active and not on a different page.",
+ "If a selector fails: inspect the page structure, consider adding data-testid attributes for unstable elements.",
+ "If a controlled input does not update: use the appropriate input simulation technique for the framework (React needs InputEvent dispatch).",
+ "If logs show errors unrelated to the test flow: note them as pre-existing issues and focus on the target flow."
+ ],
+ "expectedOutputs": [
+ "test_get_result with status 'passed' or 'failed' and step details.",
+ "log_read output showing browser console entries during and after the test run.",
+ "A clear verification report."
+ ],
+ "expectedNoise": [
+ "Map tile 404s when no local tileserver is running.",
+ "Font or stylesheet loading warnings from CDN dependencies.",
+ "React development-mode warnings (e.g. key props, deprecated lifecycle methods).",
+ "Service worker registration messages."
+ ],
+ "tags": ["verification", "testing", "ui", "workflow", "devglide-test", "devglide-log"],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+}
diff --git a/src/apps/documentation/services/documentation-store.ts b/src/apps/documentation/services/documentation-store.ts
new file mode 100644
index 0000000..826c3a5
--- /dev/null
+++ b/src/apps/documentation/services/documentation-store.ts
@@ -0,0 +1,460 @@
+import fs from 'fs/promises';
+import path from 'path';
+import type { DocEntry, DocSummary, DocType } from '../types.js';
+import { getActiveProject } from '../../../project-context.js';
+import { DOCUMENTATION_DIR, PROJECTS_DIR } from '../../../packages/paths.js';
+import { JsonFileStore } from '../../../packages/json-file-store.js';
+import { SEED_ENTRIES } from './seed-data.js';
+
+/**
+ * Per-project and global documentation storage.
+ * One JSON file per entry for git-friendly diffs.
+ * Global: ~/.devglide/documentation/
+ * Per-project: ~/.devglide/projects/{projectId}/documentation/
+ */
+export class DocumentationStore extends JsonFileStore {
+ private static instance: DocumentationStore;
+ protected readonly baseDir = DOCUMENTATION_DIR;
+
+ private seedDone = false;
+
+ static getInstance(): DocumentationStore {
+ if (!DocumentationStore.instance) {
+ DocumentationStore.instance = new DocumentationStore();
+ }
+ return DocumentationStore.instance;
+ }
+
+ /**
+ * Write embedded seed entries into the global documentation directory
+ * if they do not already exist. Uses in-memory seed data so it works
+ * in both source mode (tsx) and bundled mode (dist/mcp/*.mjs).
+ */
+ private async ensureSeeded(): Promise {
+ if (this.seedDone) return;
+ this.seedDone = true;
+
+ const globalDir = this.getGlobalDir();
+ await this.ensureDir(globalDir);
+
+ for (const entry of SEED_ENTRIES) {
+ const targetPath = path.join(globalDir, `${entry.id}.json`);
+ try {
+ await fs.access(targetPath);
+ // Already exists — skip
+ } catch {
+ await fs.writeFile(targetPath, JSON.stringify(entry, null, 2));
+ }
+ }
+ }
+
+ // ── List ──────────────────────────────────────────────────────────────────
+
+ async list(filter?: { type?: DocType; toolName?: string; tag?: string }): Promise {
+ await this.ensureSeeded();
+ const seen = new Map();
+
+ const projectDir = this.getProjectDir();
+ if (projectDir) {
+ for (const s of await this.scanDir(projectDir, 'project')) {
+ seen.set(s.id, s);
+ }
+ for (const s of await this.scanDir(this.getGlobalDir(), 'global')) {
+ if (!seen.has(s.id)) seen.set(s.id, s);
+ }
+ } else {
+ for (const s of await this.scanDir(this.getGlobalDir(), 'global')) {
+ if (!seen.has(s.id)) seen.set(s.id, s);
+ }
+ }
+
+ let results = [...seen.values()];
+
+ if (filter?.type) {
+ results = results.filter((e) => e.type === filter.type);
+ }
+ if (filter?.toolName) {
+ const name = filter.toolName.toLowerCase();
+ results = results.filter((e) => e.title.toLowerCase().includes(name) || e.summary.toLowerCase().includes(name));
+ }
+ if (filter?.tag) {
+ results = results.filter((e) => e.tags.includes(filter.tag!));
+ }
+
+ return results;
+ }
+
+ // ── Match (keyword search) ────────────────────────────────────────────────
+
+ async match(query: string): Promise {
+ const all = await this.list();
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
+ if (terms.length === 0) return all;
+
+ const scored: Array<{ entry: DocSummary; score: number }> = [];
+
+ for (const entry of all) {
+ const haystack = [entry.title, entry.summary, ...entry.tags, entry.type].join(' ').toLowerCase();
+ let score = 0;
+ for (const term of terms) {
+ if (haystack.includes(term)) score++;
+ }
+ if (score > 0) scored.push({ entry, score });
+ }
+
+ scored.sort((a, b) => b.score - a.score);
+ return scored.map((s) => s.entry);
+ }
+
+ // ── Save (with validation) ────────────────────────────────────────────────
+
+ async save(
+ input: Omit & { id?: string; scope?: 'project' | 'global' },
+ ): Promise {
+ // Validate required fields per type
+ this.validateEntry(input);
+
+ const lockKey = input.id ?? this.generateId();
+ return this.withLock(lockKey, async () => {
+ const now = new Date().toISOString();
+ const isUpdate = !!input.id;
+
+ let existing: DocEntry | null = null;
+ if (isUpdate) {
+ existing = await this.get(input.id!);
+ }
+
+ const entry = {
+ ...input,
+ id: input.id ?? lockKey,
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ } as DocEntry;
+
+ let scope = input.scope;
+ if (!scope && isUpdate) {
+ scope = await this.resolveExistingScope(input.id!);
+ }
+ scope = scope ?? (getActiveProject() ? 'project' : 'global');
+
+ await this.writeEntity(entry, scope, getActiveProject()?.id);
+ return entry;
+ });
+ }
+
+ // ── Compiled context ──────────────────────────────────────────────────────
+
+ async getCompiledContext(query?: string, projectId?: string): Promise {
+ await this.ensureSeeded();
+ let entries: DocEntry[];
+
+ if (query) {
+ // match() calls list() which uses active project context.
+ // If projectId is specified, also scan that project dir explicitly.
+ const summaries = await this.match(query);
+ const fullEntries: DocEntry[] = [];
+ for (const s of summaries.slice(0, 10)) {
+ const full = await this.get(s.id);
+ if (full) fullEntries.push(full);
+ }
+
+ // Merge in entries from the specified project dir if it differs from active
+ if (projectId) {
+ const projectOverrides = await this.listFullForProject(projectId);
+ for (const e of projectOverrides) {
+ if (!fullEntries.some((f) => f.id === e.id)) {
+ fullEntries.push(e);
+ }
+ }
+ }
+
+ entries = fullEntries;
+ } else {
+ entries = await this.listFull();
+ // Merge in entries from the specified project dir if it differs from active
+ if (projectId) {
+ const projectOverrides = await this.listFullForProject(projectId);
+ for (const e of projectOverrides) {
+ if (!entries.some((f) => f.id === e.id)) {
+ entries.push(e);
+ }
+ }
+ }
+ }
+
+ if (projectId) {
+ // Filter out entries from other projects, keep global + target project
+ entries = entries.filter((e) => !e.projectId || e.projectId === projectId);
+ }
+
+ if (entries.length === 0) return '';
+
+ const lines: string[] = ['# DevGlide Documentation Context', ''];
+
+ const byType = new Map();
+ for (const entry of entries) {
+ if (!byType.has(entry.type)) byType.set(entry.type, []);
+ byType.get(entry.type)!.push(entry);
+ }
+
+ const typeLabels: Record = {
+ 'tool-guide': 'Tool Guides',
+ 'workflow': 'Workflows',
+ 'example': 'Examples',
+ 'troubleshooting': 'Troubleshooting',
+ 'project-override': 'Project Overrides',
+ };
+
+ for (const [type, typeEntries] of byType) {
+ lines.push(`## ${typeLabels[type] ?? type}`, '');
+ for (const entry of typeEntries) {
+ lines.push(this.renderEntry(entry), '');
+ }
+ }
+
+ return lines.join('\n');
+ }
+
+ // ── Specific getters ──────────────────────────────────────────────────────
+
+ async getToolGuide(toolName: string): Promise {
+ await this.ensureSeeded();
+ const all = await this.listFull();
+ const normalized = toolName.toLowerCase();
+ return all.find((e) => e.type === 'tool-guide' && (e as any).toolName.toLowerCase() === normalized) ?? null;
+ }
+
+ async getWorkflow(name: string): Promise {
+ await this.ensureSeeded();
+ const all = await this.listFull();
+ const normalized = name.toLowerCase();
+ return all.find((e) => e.type === 'workflow' && (e as any).name.toLowerCase() === normalized) ?? null;
+ }
+
+ async getTroubleshooting(toolName: string, symptom: string): Promise {
+ await this.ensureSeeded();
+ const all = await this.listFull();
+ const normalizedTool = toolName.toLowerCase();
+ const normalizedSymptom = symptom.toLowerCase();
+
+ return all.filter((e) => {
+ if (e.type !== 'troubleshooting') return false;
+ const ts = e as any;
+ const toolMatch = ts.toolName.toLowerCase() === normalizedTool;
+ const symptomMatch = ts.symptom.toLowerCase().includes(normalizedSymptom);
+ return toolMatch && symptomMatch;
+ });
+ }
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ private validateEntry(input: Record): void {
+ const type = input.type as string;
+ if (!type) throw new Error('type is required');
+
+ // Ensure tags is an array
+ if (!Array.isArray(input.tags)) input.tags = [];
+
+ switch (type) {
+ case 'tool-guide':
+ this.requireStrings(input, ['toolName', 'summary', 'executionModel']);
+ this.requireArrays(input, ['prerequisites', 'preferredPatterns', 'antiPatterns', 'followUpChecks', 'commonFailures', 'seeAlso']);
+ if (!input.resultSemantics || typeof input.resultSemantics !== 'object') input.resultSemantics = {};
+ if (!input.inputsExplained || typeof input.inputsExplained !== 'object') input.inputsExplained = {};
+ break;
+ case 'workflow':
+ this.requireStrings(input, ['name', 'goal']);
+ this.requireArrays(input, ['toolsInvolved', 'preflight', 'stepSequence', 'successCriteria', 'failureBranches', 'expectedOutputs', 'expectedNoise']);
+ break;
+ case 'example':
+ this.requireStrings(input, ['toolName', 'scenario']);
+ this.requireArrays(input, ['startingAssumptions', 'toolSequence', 'whatGoodLooksLike', 'whatBadLooksLike', 'whatToDoNext']);
+ break;
+ case 'troubleshooting':
+ this.requireStrings(input, ['toolName', 'symptom']);
+ this.requireArrays(input, ['likelyCauses', 'howToDiagnose', 'howToFix']);
+ if (typeof input.whenToRetry !== 'string') input.whenToRetry = '';
+ break;
+ case 'project-override':
+ this.requireStrings(input, ['targetToolName', 'notes']);
+ if (!input.overrides || typeof input.overrides !== 'object') input.overrides = {};
+ break;
+ default:
+ throw new Error(`Unknown document type: ${type}`);
+ }
+ }
+
+ private requireStrings(input: Record, fields: string[]): void {
+ for (const field of fields) {
+ if (typeof input[field] !== 'string') {
+ throw new Error(`${field} is required and must be a string`);
+ }
+ }
+ }
+
+ private requireArrays(input: Record, fields: string[]): void {
+ for (const field of fields) {
+ if (!Array.isArray(input[field])) input[field] = [];
+ }
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ private async listFull(): Promise {
+ const seen = new Map();
+
+ const projectDir = this.getProjectDir();
+ if (projectDir) {
+ for (const e of await this.scanDirFull(projectDir)) {
+ seen.set(e.id, e);
+ }
+ for (const e of await this.scanDirFull(this.getGlobalDir())) {
+ if (!seen.has(e.id)) seen.set(e.id, e);
+ }
+ } else {
+ for (const e of await this.scanDirFull(this.getGlobalDir())) {
+ if (!seen.has(e.id)) seen.set(e.id, e);
+ }
+ }
+
+ return [...seen.values()];
+ }
+
+ /**
+ * Scan a specific project's documentation directory by projectId,
+ * regardless of which project is currently active.
+ */
+ private async listFullForProject(projectId: string): Promise {
+ const featureName = path.basename(this.baseDir);
+ const projectDir = path.join(PROJECTS_DIR, projectId, featureName);
+ return this.scanDirFull(projectDir);
+ }
+
+ private toSummary(entry: DocEntry, scope: 'project' | 'global'): DocSummary {
+ return {
+ id: entry.id,
+ type: entry.type,
+ title: this.getTitle(entry),
+ summary: this.getSummary(entry),
+ tags: entry.tags ?? [],
+ scope,
+ updatedAt: entry.updatedAt,
+ };
+ }
+
+ private getTitle(entry: DocEntry): string {
+ switch (entry.type) {
+ case 'tool-guide': return entry.toolName;
+ case 'workflow': return entry.name;
+ case 'example': return `${entry.toolName}: ${entry.scenario}`;
+ case 'troubleshooting': return `${entry.toolName}: ${entry.symptom}`;
+ case 'project-override': return `Override: ${entry.targetToolName}`;
+ }
+ }
+
+ private getSummary(entry: DocEntry): string {
+ switch (entry.type) {
+ case 'tool-guide': return entry.summary;
+ case 'workflow': return entry.goal;
+ case 'example': return entry.scenario;
+ case 'troubleshooting': return entry.symptom;
+ case 'project-override': return entry.notes;
+ }
+ }
+
+ private renderEntry(entry: DocEntry): string {
+ switch (entry.type) {
+ case 'tool-guide':
+ return [
+ `### ${entry.toolName}`,
+ entry.summary,
+ '',
+ `**Execution model:** ${entry.executionModel}`,
+ '',
+ '**Prerequisites:**',
+ ...(entry.prerequisites ?? []).map((p) => `- ${p}`),
+ '',
+ '**Result semantics:**',
+ ...Object.entries(entry.resultSemantics ?? {}).map(([k, v]) => `- \`${k}\`: ${v}`),
+ '',
+ '**Preferred patterns:**',
+ ...(entry.preferredPatterns ?? []).map((p) => `- ${p}`),
+ '',
+ '**Anti-patterns:**',
+ ...(entry.antiPatterns ?? []).map((p) => `- ${p}`),
+ '',
+ '**Follow-up checks:**',
+ ...(entry.followUpChecks ?? []).map((c) => `- ${c}`),
+ ].join('\n');
+
+ case 'workflow':
+ return [
+ `### ${entry.name}`,
+ entry.goal,
+ '',
+ `**Tools:** ${(entry.toolsInvolved ?? []).join(', ')}`,
+ '',
+ '**Steps:**',
+ ...(entry.stepSequence ?? []).map((s, i) => `${i + 1}. ${s}`),
+ '',
+ '**Success criteria:**',
+ ...(entry.successCriteria ?? []).map((c) => `- ${c}`),
+ '',
+ '**Failure branches:**',
+ ...(entry.failureBranches ?? []).map((f) => `- ${f}`),
+ ].join('\n');
+
+ case 'example':
+ return [
+ `### ${entry.toolName}: ${entry.scenario}`,
+ '',
+ '**Starting assumptions:**',
+ ...(entry.startingAssumptions ?? []).map((a) => `- ${a}`),
+ '',
+ '**Tool sequence:**',
+ ...(entry.toolSequence ?? []).map((s, i) => `${i + 1}. ${s}`),
+ '',
+ '**Good outcome:**',
+ ...(entry.whatGoodLooksLike ?? []).map((g) => `- ${g}`),
+ '',
+ '**Bad outcome:**',
+ ...(entry.whatBadLooksLike ?? []).map((b) => `- ${b}`),
+ '',
+ '**Next steps if bad:**',
+ ...(entry.whatToDoNext ?? []).map((n) => `- ${n}`),
+ ].join('\n');
+
+ case 'troubleshooting':
+ return [
+ `### ${entry.toolName}: ${entry.symptom}`,
+ '',
+ '**Likely causes:**',
+ ...(entry.likelyCauses ?? []).map((c) => `- ${c}`),
+ '',
+ '**How to diagnose:**',
+ ...(entry.howToDiagnose ?? []).map((d) => `- ${d}`),
+ '',
+ '**How to fix:**',
+ ...(entry.howToFix ?? []).map((f) => `- ${f}`),
+ '',
+ `**When to retry:** ${entry.whenToRetry ?? ''}`,
+ ].join('\n');
+
+ case 'project-override':
+ return [
+ `### Override: ${entry.targetToolName}`,
+ entry.notes,
+ '',
+ '**Overrides:**',
+ '```json',
+ JSON.stringify(entry.overrides ?? {}, null, 2),
+ '```',
+ ].join('\n');
+ }
+ }
+
+ private async scanDir(dir: string, scope: 'project' | 'global'): Promise {
+ const entries = await this.scanDirFull(dir);
+ return entries.map((e) => this.toSummary(e, scope));
+ }
+}
diff --git a/src/apps/documentation/services/seed-data.ts b/src/apps/documentation/services/seed-data.ts
new file mode 100644
index 0000000..726a19d
--- /dev/null
+++ b/src/apps/documentation/services/seed-data.ts
@@ -0,0 +1,598 @@
+import type { DocEntry } from '../types.js';
+
+/**
+ * Embedded seed documentation entries.
+ * These are written to ~/.devglide/documentation/ on first use if not already present.
+ * Embedded in code so the bundled MCP server (dist/mcp/documentation.mjs) does not
+ * need to locate seed files on disk.
+ */
+export const SEED_ENTRIES: DocEntry[] = [
+ {
+ "id": "ex-flaky-selector",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Flaky selector in React controlled form",
+ "startingAssumptions": [
+ "A test scenario intermittently fails on a click or type step targeting a form element.",
+ "The selector works when tested manually in the browser DevTools.",
+ "The app uses React with controlled form inputs."
+ ],
+ "toolSequence": [
+ "test_get_result — read the failure details. Note which step failed and the selector used.",
+ "Inspect the app source code to check if the element has stable attributes (id, data-testid, role, aria-label).",
+ "If the selector uses dynamic class names (CSS modules, styled-components): it will break between builds.",
+ "If the element is rendered conditionally or inside a transition: add a wait-for-element step before the interaction.",
+ "Add a data-testid attribute to the element in the app source code if no stable selector exists.",
+ "Update the scenario to use the data-testid selector and add a wait step.",
+ "Re-run the scenario multiple times to verify the fix is stable."
+ ],
+ "whatGoodLooksLike": [
+ "The scenario passes consistently across multiple runs.",
+ "The selector uses a stable attribute that will not change between builds.",
+ "The wait step ensures the element is rendered before interaction."
+ ],
+ "whatBadLooksLike": [
+ "The scenario still fails intermittently — the timing issue is not fully resolved.",
+ "Adding data-testid changes app behavior (unlikely but possible if the attribute conflicts)."
+ ],
+ "whatToDoNext": [
+ "If still flaky: increase the wait timeout or add an assertion that the element is visible before interacting.",
+ "If the form field is a complex component (date picker, autocomplete): interact via its UI controls rather than targeting the underlying input directly.",
+ "Consider whether the React component needs a fix for accessibility — stable selectors often align with proper ARIA attributes."
+ ],
+ "tags": [
+ "example",
+ "test",
+ "selector",
+ "react",
+ "form",
+ "flaky"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ex-no-result-recovery",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Scenario returns no result found — recovery steps",
+ "startingAssumptions": [
+ "You submitted a scenario via test_run_scenario.",
+ "test_get_result returns 'no result found' after waiting."
+ ],
+ "toolSequence": [
+ "test_get_result — confirm the 'no result found' response.",
+ "log_read — check if devtools.js is posting any log entries. If no recent entries, devtools.js is not active.",
+ "shell_run_command to check if the dev server process is running.",
+ "If the dev server is running: the issue is the browser. Ask the user to verify the browser tab is open on the app.",
+ "If devtools.js is not in the page source: instruct the user to add the devtools.js script tag.",
+ "After the browser is confirmed ready: re-submit the scenario via test_run_scenario.",
+ "test_get_result — should now return a real result."
+ ],
+ "whatGoodLooksLike": [
+ "After recovery, test_get_result returns 'passed' or 'failed' (not 'no result found').",
+ "log_read shows fresh entries from the current browser session."
+ ],
+ "whatBadLooksLike": [
+ "test_get_result still returns 'no result found' after recovery steps.",
+ "log_read shows no entries — devtools.js is still not connected."
+ ],
+ "whatToDoNext": [
+ "If still no result: check that the DevGlide server port matches what devtools.js is configured for.",
+ "Check the browser console directly for devtools.js initialization errors.",
+ "As a last resort, fully close and reopen the browser, navigate to the app, and retry."
+ ],
+ "tags": [
+ "example",
+ "test",
+ "no-result",
+ "recovery"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ex-pass-with-errors",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Scenario passes but logs show runtime exception",
+ "startingAssumptions": [
+ "A test scenario just completed with status 'passed'.",
+ "You are about to check logs as part of the verification workflow."
+ ],
+ "toolSequence": [
+ "test_get_result — confirms 'passed'.",
+ "log_read with level filter 'error' — read error-level log entries.",
+ "Identify the error: read the message, stack trace, and timestamp.",
+ "Correlate the error timestamp with the scenario steps to find the trigger.",
+ "Determine if the error is in app code (fixable) or third-party (document as noise).",
+ "If fixable: fix the app code, re-run the scenario, and re-check logs.",
+ "If third-party noise: document it in a project override via docs_add."
+ ],
+ "whatGoodLooksLike": [
+ "After the fix, the scenario still passes AND logs are clean of unexpected errors.",
+ "If the error was third-party noise: a project override documents it for future runs."
+ ],
+ "whatBadLooksLike": [
+ "The fix breaks the scenario — it now fails.",
+ "New errors appear after the fix.",
+ "The error is intermittent and hard to reproduce."
+ ],
+ "whatToDoNext": [
+ "If the fix broke the scenario: the fix may be incorrect. Revert and investigate further.",
+ "If new errors appeared: the fix had side effects. Review the change carefully.",
+ "If intermittent: run the scenario multiple times and check logs each time to gather more data."
+ ],
+ "tags": [
+ "example",
+ "test",
+ "log",
+ "runtime-error",
+ "false-positive"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ex-test-ui-flow",
+ "type": "example",
+ "toolName": "devglide-test",
+ "scenario": "Test a UI flow and inspect logs after run",
+ "startingAssumptions": [
+ "The app dev server is running.",
+ "A browser page is open on the app with devtools.js loaded.",
+ "The DevGlide server is running on port 7000.",
+ "You know which UI flow to test (e.g. 'create a new item', 'submit a form')."
+ ],
+ "toolSequence": [
+ "test_list_saved — check if a relevant saved scenario exists.",
+ "test_run_scenario with a natural language description of the flow (e.g. 'Navigate to the form page, fill in the name field with \"Test Club\", select a category, and click Submit. Verify a success message appears.')",
+ "Wait 3-5 seconds for the browser to consume and execute the scenario.",
+ "test_get_result — read the scenario outcome.",
+ "log_read with targetPath for the project log — read the browser console output after the run.",
+ "Assess: scenario passed + no unexpected errors in logs = verification success."
+ ],
+ "whatGoodLooksLike": [
+ "test_get_result returns status 'passed' with all steps completed.",
+ "log_read shows normal app output — no errors, only expected info/debug messages.",
+ "The UI reflects the expected state after the flow (e.g. the new item appears in a list)."
+ ],
+ "whatBadLooksLike": [
+ "test_get_result returns 'no result found' — the browser did not consume the scenario.",
+ "test_get_result returns status 'failed' with a step failure.",
+ "log_read shows error-level entries (unhandled exceptions, assertion failures).",
+ "test_get_result returns 'passed' but logs contain runtime errors."
+ ],
+ "whatToDoNext": [
+ "If 'no result found': verify browser is open with devtools.js, then retry.",
+ "If a step failed: read the failure details, check the selector and timing, and fix the scenario or app code.",
+ "If logs show errors: diagnose the root cause from the error message and stack trace, fix the app, and re-run.",
+ "If the scenario passed but logs have errors: treat as failure — add assertions or fix the underlying error."
+ ],
+ "tags": [
+ "example",
+ "test",
+ "log",
+ "verification",
+ "ui"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "guide-devglide-log",
+ "type": "tool-guide",
+ "toolName": "devglide-log",
+ "summary": "Read and manage structured log files captured from browser console output and server-side processes. Used alongside devglide-test to verify that apps run without runtime errors.",
+ "executionModel": "File-based log capture. The browser-side sniffer (devtools.js) intercepts console.log/warn/error calls and POSTs them to the DevGlide server, which appends them to a log file. Server-side logs are captured separately. log_read returns recent entries from a target log file.",
+ "prerequisites": [
+ "devtools.js must be loaded in the browser page for browser-side log capture.",
+ "The DevGlide server must be running to receive log POST requests.",
+ "A log session must be active — devtools.js creates a session on page load."
+ ],
+ "inputsExplained": {
+ "targetPath": "Path to the log file to read. For project-scoped logs, this is typically ~/.devglide/projects/{projectId}/logs/{project-name}-console.log. If omitted, reads the default DevGlide console log.",
+ "lines": "Number of recent lines to return. Defaults to 50.",
+ "level": "Optional filter by log level (log, warn, error, info, debug)."
+ },
+ "resultSemantics": {
+ "entries_returned": "Log entries matching the query. Each entry includes timestamp, level, source, and message.",
+ "empty_result": "No log entries found. This may mean: no logs captured yet, wrong targetPath, or devtools.js not loaded.",
+ "error_entries": "Entries with level 'error' indicate runtime exceptions or failed assertions in the app."
+ },
+ "preferredPatterns": [
+ "Always read logs after every devglide-test scenario run — log review is mandatory for verification, not optional.",
+ "To find the correct log path for a project: the pattern is ~/.devglide/projects/{projectId}/logs/{project-name}-console.log where project-name is the name registered in DevGlide.",
+ "Filter by level 'error' first to quickly identify runtime failures.",
+ "Distinguish expected noise from real failures. Common expected noise includes: map tile 404s, font loading warnings, style sheet 404s from optional dependencies, and development-mode React warnings.",
+ "When a scenario passes but logs show errors, treat the errors as failures — a passing scenario does not mean the app is healthy."
+ ],
+ "antiPatterns": [
+ "Do not skip log review after test runs. A scenario can pass while the app throws unhandled exceptions.",
+ "Do not assume an empty log means success — it may mean devtools.js is not capturing.",
+ "Do not treat all warnings as failures. Many frameworks emit development-mode warnings that are not bugs.",
+ "Do not read logs from a stale session. Check the session timestamp to ensure you are reading current output."
+ ],
+ "followUpChecks": [
+ "After identifying errors in logs, correlate them with the scenario step that was executing at that timestamp.",
+ "If logs show errors but the scenario passed, the error may be in a background process or async operation not covered by the scenario steps."
+ ],
+ "commonFailures": [
+ "No log entries despite running the app — devtools.js not loaded or posting to wrong server URL.",
+ "Log file path does not exist — project not registered in DevGlide or using wrong project name.",
+ "Logs show hundreds of entries — filter by level or use lines parameter to limit output."
+ ],
+ "seeAlso": [
+ "devglide-test"
+ ],
+ "tags": [
+ "log",
+ "debugging",
+ "verification",
+ "console"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "guide-devglide-test",
+ "type": "tool-guide",
+ "toolName": "devglide-test",
+ "summary": "Run browser automation scenarios against an app instrumented with devtools.js. Scenarios are described in natural language, translated into browser commands, and executed inside a real browser page.",
+ "executionModel": "Browser-driven, not server-driven. Submitting a scenario via test_run_scenario or test_run_saved only queues work on the server. Actual execution happens inside the browser page where devtools.js is loaded — it polls the server for pending scenarios and runs them in-page. The server never drives the browser directly.",
+ "prerequisites": [
+ "The app's dev server must be running and reachable at its expected URL.",
+ "A real browser page must be open on the app (not just a server process).",
+ "devtools.js must be loaded in the page — add to the app's HTML in development.",
+ "The browser session must be active — devtools.js polls the DevGlide server; if the tab is closed or navigated away, polling stops.",
+ "The DevGlide server must be running (default port 7000)."
+ ],
+ "inputsExplained": {
+ "scenario": "A natural-language description of what to test. DevGlide translates this into a sequence of browser commands (navigate, click, type, assert, wait, etc.).",
+ "target": "The base URL of the app under test. Resolved from the active project if not specified.",
+ "steps": "When using test_run_scenario with explicit steps, each step is a command object (e.g. { command: 'click', selector: '#submit' })."
+ },
+ "resultSemantics": {
+ "passed": "All steps completed successfully. The scenario ran to completion without assertion failures.",
+ "failed": "One or more steps failed. Inspect the failed step details and check devglide-log for runtime errors.",
+ "no_result_found": "The browser has NOT consumed the scenario yet. This is NOT a test failure — it means devtools.js has not polled or the page is not open. Wait briefly and retry test_get_result, or verify the browser is open with devtools.js loaded."
+ },
+ "preferredPatterns": [
+ "Use click-based navigation after the initial page load. Simulate what a real user would do — click links, buttons, and menu items rather than navigating via URL.",
+ "Wait for state changes, not fixed timeouts. Use assertions or wait-for-element steps rather than arbitrary sleep durations.",
+ "Keep scenarios focused on one user flow. A scenario that tests club creation should not also test user settings.",
+ "Inspect devglide-log after every run — even if the scenario passes, the logs may contain runtime errors or unexpected warnings.",
+ "For stateful React forms with controlled inputs, add targeted data-testid attributes only where semantic selectors (role, label, placeholder) are unstable.",
+ "Prefer realistic data in scenarios — use plausible names, emails, and values rather than 'test123'."
+ ],
+ "antiPatterns": [
+ "Do not treat 'no result found' as a normal test failure. It means the scenario was not consumed by the browser.",
+ "Do not use excessive navigate steps. Each full navigation can reset React state, unmount components, and break stateful flows.",
+ "Do not rely only on scenario pass/fail without reviewing logs. A passing scenario can mask runtime errors visible in the console.",
+ "Do not use fragile CSS selectors like nth-child or deeply nested class chains. Prefer data-testid, role, or label-based selectors.",
+ "Do not run scenarios against a page that has not finished loading. Wait for the app to be interactive before submitting."
+ ],
+ "followUpChecks": [
+ "Read the browser log via devglide-log after every scenario run.",
+ "Distinguish expected noise (e.g. map tile 404s, style loading warnings) from real failures (unhandled exceptions, assertion errors).",
+ "If the scenario fails, check both the step failure details AND the logs — the root cause is often visible in the console before the step that failed."
+ ],
+ "commonFailures": [
+ "Scenario queued but never runs — browser tab closed, devtools.js not loaded, or wrong target URL.",
+ "Selector works in DevTools but automation fails — element not yet rendered, inside shadow DOM, or hidden behind an overlay.",
+ "React controlled input does not update — automation types text but React state does not reflect it. Use dispatchEvent with InputEvent or interact via the React fiber.",
+ "Navigate causes full reload and context loss — stateful app loses form data mid-flow. Use click navigation instead."
+ ],
+ "seeAlso": [
+ "devglide-log"
+ ],
+ "tags": [
+ "test",
+ "browser",
+ "automation",
+ "devtools"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-controlled-input",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "React controlled input does not update as expected",
+ "likelyCauses": [
+ "The automation sets the input value directly (e.g. element.value = 'text') but React does not recognize the change because no synthetic event was fired.",
+ "React controlled inputs require an InputEvent or Change event dispatched through the React event system to trigger state updates.",
+ "The input has a debounce or validation handler that delays or rejects the change."
+ ],
+ "howToDiagnose": [
+ "Check if the input is controlled (has a value prop bound to React state).",
+ "After the type step, check whether the displayed value matches what was typed.",
+ "Look at React DevTools to see if the component state updated.",
+ "Check the browser console for React warnings about uncontrolled-to-controlled transitions."
+ ],
+ "howToFix": [
+ "Use the type command which simulates individual key presses — this usually fires the correct events for React.",
+ "If type does not work, the scenario may need to dispatch a native InputEvent: new InputEvent('input', { bubbles: true, data: 'text' }).",
+ "For complex form fields (date pickers, rich text editors), interact through their UI controls rather than setting values directly.",
+ "Add a data-testid to the input and verify the value via assertion after typing."
+ ],
+ "whenToRetry": "After switching to the type command or implementing proper event dispatch.",
+ "tags": [
+ "test",
+ "react",
+ "input",
+ "controlled",
+ "form"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-expected-warnings",
+ "type": "troubleshooting",
+ "toolName": "devglide-log",
+ "symptom": "logs contain expected app-specific warnings",
+ "likelyCauses": [
+ "The app has known non-critical warnings that appear during normal operation.",
+ "Common examples: map tile 404s when no local tileserver is running, font loading failures from CDN, stylesheet 404s from optional dependencies, React development-mode warnings.",
+ "These are not bugs — they are expected noise for the current development environment."
+ ],
+ "howToDiagnose": [
+ "Check the log level — warnings (level: warn) are typically non-critical.",
+ "Check if the warning message matches known patterns for the app (e.g. 'Failed to load tile', '404 for /fonts/', 'React does not recognize the X prop').",
+ "Check project documentation or overrides for a list of known expected warnings.",
+ "If unsure, ask the user whether the warning is expected for their app."
+ ],
+ "howToFix": [
+ "Do not treat expected warnings as failures in verification.",
+ "Document known expected warnings in a project override so other agents can distinguish them from real failures.",
+ "If the warning is unexpected: investigate whether a dependency is missing or misconfigured.",
+ "Filter log output by level 'error' to focus on real failures."
+ ],
+ "whenToRetry": "Not applicable — this is about correct interpretation, not a fixable failure.",
+ "tags": [
+ "log",
+ "warnings",
+ "noise",
+ "expected"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-navigate-reload",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "navigate causes reload and context loss",
+ "likelyCauses": [
+ "The scenario uses a navigate step that triggers a full page reload instead of client-side routing.",
+ "The app is a single-page app (SPA) but the navigate step goes to a different origin or uses a full URL that bypasses the client router.",
+ "React or framework state (form data, auth tokens, component state) is lost on full reload.",
+ "devtools.js must re-initialize after a full page reload, causing a gap in the polling loop."
+ ],
+ "howToDiagnose": [
+ "Check the scenario steps — is there a navigate command mid-flow?",
+ "Check if the app uses client-side routing (React Router, Next.js, etc.).",
+ "Look at the browser network tab to confirm whether a full page load occurred."
+ ],
+ "howToFix": [
+ "Replace navigate steps with click-based navigation. Click the link, button, or menu item that the user would click to reach that page.",
+ "If a navigate is necessary (e.g. initial page load), keep it as the first step only.",
+ "For SPAs, ensure the navigate URL uses the same origin and lets the client router handle routing.",
+ "If form state is lost: restructure the scenario to complete one form before navigating away."
+ ],
+ "whenToRetry": "After rewriting the scenario to use click-based navigation instead of navigate steps.",
+ "tags": [
+ "test",
+ "navigate",
+ "reload",
+ "spa",
+ "state-loss"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-no-result-found",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "test_get_result returns no result found",
+ "likelyCauses": [
+ "The browser page is not open or the tab is inactive.",
+ "devtools.js is not loaded in the page.",
+ "The browser is open on a different URL than the target app.",
+ "devtools.js polling was interrupted (page navigated away, tab crashed, or JavaScript error blocked polling).",
+ "The DevGlide server restarted after the scenario was submitted but before the browser consumed it."
+ ],
+ "howToDiagnose": [
+ "Check that a browser page is open on the target app URL.",
+ "Open the browser DevTools console and look for 'devglide' messages confirming devtools.js is active.",
+ "Check devglide-log for recent session entries — if there are none, devtools.js is not connected.",
+ "Verify the DevGlide server is running on the expected port (default 7000)."
+ ],
+ "howToFix": [
+ "Open the app in a browser if no page is open.",
+ "Add to the app's development HTML if devtools.js is missing.",
+ "Refresh the browser page to restart the devtools.js polling loop.",
+ "Re-submit the scenario after confirming the browser is ready."
+ ],
+ "whenToRetry": "After confirming the browser page is open and devtools.js is loaded. Wait 2-5 seconds after the fix, then call test_get_result again.",
+ "tags": [
+ "test",
+ "no-result",
+ "browser",
+ "devtools"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-pass-but-errors",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "scenario passes but console logs show runtime error",
+ "likelyCauses": [
+ "The runtime error occurs in async code (setTimeout, Promise, event handler) that is not part of the synchronous scenario execution flow.",
+ "The error occurs in a background component or service worker, not in the component being tested.",
+ "The scenario assertions do not check for error states — they only verify the happy path.",
+ "A race condition causes the error only sometimes, depending on timing."
+ ],
+ "howToDiagnose": [
+ "Read the full log output after the scenario run. Filter by level 'error'.",
+ "Correlate the error timestamp with the scenario steps to identify which action triggered it.",
+ "Check if the error is in app code or a third-party library.",
+ "Run the scenario multiple times to check if the error is consistent or intermittent."
+ ],
+ "howToFix": [
+ "Treat the runtime error as a real failure even though the scenario passed — the scenario assertions were incomplete.",
+ "Fix the runtime error in the app code.",
+ "Add assertion steps to the scenario that verify no error state is visible (e.g. check that no error toast or banner appeared).",
+ "If the error is in a third-party library and cannot be fixed: document it as expected noise in a project override."
+ ],
+ "whenToRetry": "After fixing the runtime error. Re-run the scenario and verify that both the scenario passes AND logs are clean.",
+ "tags": [
+ "test",
+ "log",
+ "runtime-error",
+ "false-positive"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-scenario-never-runs",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "scenario accepted but never runs",
+ "likelyCauses": [
+ "devtools.js is loaded but polling is blocked by a JavaScript error in the app.",
+ "The app page is open but on a route that does not load devtools.js (e.g. a separate login page or error page).",
+ "A previous scenario is still running or stuck, blocking the queue.",
+ "The target URL in the scenario does not match the page currently open in the browser.",
+ "Browser DevTools is paused on a breakpoint, blocking script execution."
+ ],
+ "howToDiagnose": [
+ "Check the browser console for JavaScript errors that may have halted devtools.js.",
+ "Verify the page URL matches the expected target for the scenario.",
+ "Check if a previous scenario result is pending via test_get_result.",
+ "Look for 'devglide runner' messages in the browser console confirming the polling loop is active."
+ ],
+ "howToFix": [
+ "If a JS error is blocking devtools.js: fix the error or refresh the page.",
+ "If on the wrong page: navigate to the correct app URL.",
+ "If a previous scenario is stuck: refresh the page to clear the queue.",
+ "If DevTools is paused: resume execution."
+ ],
+ "whenToRetry": "After clearing the blocking condition. Re-submit the scenario — do not assume the old submission will eventually run.",
+ "tags": [
+ "test",
+ "scenario",
+ "stuck",
+ "polling"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "ts-selector-fails",
+ "type": "troubleshooting",
+ "toolName": "devglide-test",
+ "symptom": "selector works manually but automation fails",
+ "likelyCauses": [
+ "The element is not yet rendered when the automation tries to find it (timing issue).",
+ "The element is inside a shadow DOM and the selector does not pierce it.",
+ "The element is hidden behind a modal, overlay, or loading spinner.",
+ "The selector relies on dynamically generated class names (e.g. CSS modules, styled-components) that change between builds.",
+ "The element is in an iframe that the automation does not target."
+ ],
+ "howToDiagnose": [
+ "Add a wait-for-element step before the interaction step.",
+ "Check if the element is visible in the DOM at the time of the step (not hidden by CSS or not yet mounted).",
+ "Inspect whether the element is inside a shadow DOM boundary.",
+ "Check if the class names in the selector are stable across builds."
+ ],
+ "howToFix": [
+ "Add a wait step before interacting with the element.",
+ "Use stable selectors: data-testid, role attributes, aria-label, or visible text content.",
+ "If the element is behind a modal: add a step to dismiss the modal first.",
+ "If class names are dynamic: add a data-testid attribute to the element in the app source code.",
+ "If inside shadow DOM: use the appropriate shadow DOM piercing selector or restructure the test to avoid it."
+ ],
+ "whenToRetry": "After updating the selector or adding appropriate wait steps.",
+ "tags": [
+ "test",
+ "selector",
+ "timing",
+ "dom"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ },
+ {
+ "id": "workflow-verify-ui-flow",
+ "type": "workflow",
+ "name": "verify-ui-flow-with-devglide-test-and-devglide-log",
+ "goal": "Verify a UI flow end-to-end using browser test scenarios and log review. This is the standard verification loop for confirming that an app feature works correctly.",
+ "toolsInvolved": [
+ "devglide-test",
+ "devglide-log"
+ ],
+ "preflight": [
+ "Confirm the target app and its expected URL (check project config or ask the user).",
+ "Confirm the app's dev server is running. If not, start it via devglide-shell.",
+ "Confirm a browser page is open on the app with devtools.js loaded.",
+ "Confirm the browser session is active by checking devglide-log for recent session entries."
+ ],
+ "stepSequence": [
+ "Identify the UI flow to verify (e.g. 'create a club', 'submit a form', 'navigate to settings').",
+ "Check if a saved test scenario already exists via test_list_saved. Reuse if available.",
+ "If no saved scenario exists, create a realistic scenario describing the user flow in natural language. Use click-based navigation, realistic data, and targeted assertions.",
+ "Run the scenario via test_run_saved or test_run_scenario.",
+ "Wait briefly (2-5 seconds) then check the result via test_get_result.",
+ "If test_get_result returns 'no result found', the browser has not consumed the scenario yet. Verify the browser is open and devtools.js is loaded. Retry test_get_result after a few seconds.",
+ "Read browser logs via log_read immediately after the run — filter for errors first.",
+ "Separate expected noise from true failures. Expected noise includes: map tile 404s, font/style loading warnings, React development warnings.",
+ "If the scenario failed: examine the failed step, correlate with log entries at that timestamp, and diagnose the root cause.",
+ "If the scenario passed but logs show runtime errors: treat as a failure. The error may be in async code not covered by scenario assertions.",
+ "Fix the identified issue in the application code.",
+ "Re-run the scenario and re-check logs. Repeat until the scenario passes AND logs are clean of unexpected errors.",
+ "Report the verification result: pass/fail, what was tested, any fixes applied, and any known non-blocking warnings."
+ ],
+ "successCriteria": [
+ "The test scenario passes — all steps complete without assertion failures.",
+ "Browser logs contain no unexpected errors after the run.",
+ "Any app-specific expected noise is identified and excluded from failure assessment.",
+ "The verification result is reported clearly."
+ ],
+ "failureBranches": [
+ "If devtools.js is not loaded: guide the user to add the script tag to their app's development HTML.",
+ "If the dev server is not running: start it via shell_run_command.",
+ "If 'no result found' persists after multiple retries: check that the browser tab is active and not on a different page.",
+ "If a selector fails: inspect the page structure, consider adding data-testid attributes for unstable elements.",
+ "If a controlled input does not update: use the appropriate input simulation technique for the framework (React needs InputEvent dispatch).",
+ "If logs show errors unrelated to the test flow: note them as pre-existing issues and focus on the target flow."
+ ],
+ "expectedOutputs": [
+ "test_get_result with status 'passed' or 'failed' and step details.",
+ "log_read output showing browser console entries during and after the test run.",
+ "A clear verification report."
+ ],
+ "expectedNoise": [
+ "Map tile 404s when no local tileserver is running.",
+ "Font or stylesheet loading warnings from CDN dependencies.",
+ "React development-mode warnings (e.g. key props, deprecated lifecycle methods).",
+ "Service worker registration messages."
+ ],
+ "tags": [
+ "verification",
+ "testing",
+ "ui",
+ "workflow",
+ "devglide-test",
+ "devglide-log"
+ ],
+ "createdAt": "2026-03-24T00:00:00.000Z",
+ "updatedAt": "2026-03-24T00:00:00.000Z"
+ }
+];
diff --git a/src/apps/documentation/src/index.ts b/src/apps/documentation/src/index.ts
new file mode 100644
index 0000000..2ea9833
--- /dev/null
+++ b/src/apps/documentation/src/index.ts
@@ -0,0 +1,10 @@
+#!/usr/bin/env node
+import { createDocumentationMcpServer } from "./mcp.js";
+import { runStdio } from "../../../packages/mcp-utils/src/index.js";
+
+// ── Stdio MCP mode ──────────────────────────────────────────────────────────
+if (process.argv.includes("--stdio")) {
+ const server = createDocumentationMcpServer();
+ await runStdio(server);
+ console.error("Devglide Documentation MCP server running on stdio");
+}
diff --git a/src/apps/documentation/src/mcp.ts b/src/apps/documentation/src/mcp.ts
new file mode 100644
index 0000000..d2873b8
--- /dev/null
+++ b/src/apps/documentation/src/mcp.ts
@@ -0,0 +1,216 @@
+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { z } from 'zod';
+import { DocumentationStore } from '../services/documentation-store.js';
+import { jsonResult, errorResult, createDevglideMcpServer } from '../../../packages/mcp-utils/src/index.js';
+import type { DocType } from '../types.js';
+
+export function createDocumentationMcpServer(): McpServer {
+ const server = createDevglideMcpServer(
+ 'devglide-documentation',
+ '0.1.0',
+ 'Operational guidance for DevGlide tools — workflows, troubleshooting, examples',
+ {
+ instructions: [
+ '## Documentation — Usage Conventions',
+ '',
+ '### Purpose',
+ '- The documentation server provides operational guidance that tool schemas alone cannot carry.',
+ '- It answers: how does this tool execute, what must be running, what does a failure mean, what to do next.',
+ '- Content types: tool guides, workflows, examples, troubleshooting, project overrides.',
+ '',
+ '### When to use',
+ '- **Before using devglide-test or devglide-log** for a verification task, call `docs_context` with your task description to get the full operational loop.',
+ '- **When a tool run fails with a known symptom**, call `docs_get_troubleshooting` or `docs_match` to find diagnosis and fix guidance.',
+ '- **To discover available documentation**, call `docs_list` or `docs_match` with a keyword query.',
+ '',
+ '### Reading documentation',
+ '- Use `docs_list` to browse all available documentation entries, optionally filtered by type or tool name.',
+ '- Use `docs_match` to search documentation by keyword query — returns ranked results.',
+ '- Use `docs_get_tool_guide` to get the full operational guide for a specific tool.',
+ '- Use `docs_get_workflow` to get a step-by-step workflow by name.',
+ '- Use `docs_get_troubleshooting` to find troubleshooting entries by tool name and symptom.',
+ '- Use `docs_context` to get a compiled markdown bundle relevant to a task query — best for injection into your working context.',
+ '',
+ '### Writing documentation',
+ '- Use `docs_add` to create a new documentation entry (tool guide, workflow, example, troubleshooting, or project override).',
+ '- Use `docs_update` to modify an existing entry by ID.',
+ '- Use `docs_remove` to delete an entry by ID.',
+ ],
+ },
+ );
+
+ const store = DocumentationStore.getInstance();
+
+ // ── 1. docs_list ──────────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_list',
+ 'List all documentation entries. Optionally filter by type, tool name, or tag.',
+ {
+ type: z.string().optional().describe('Filter by content type: tool-guide, workflow, example, troubleshooting, project-override'),
+ toolName: z.string().optional().describe('Filter by tool name (e.g. "devglide-test")'),
+ tag: z.string().optional().describe('Filter by tag'),
+ },
+ async ({ type, toolName, tag }) => {
+ const entries = await store.list({
+ type: type as DocType | undefined,
+ toolName,
+ tag,
+ });
+ return jsonResult(entries);
+ },
+ );
+
+ // ── 2. docs_match ─────────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_match',
+ 'Search documentation by keyword query. Returns ranked summaries with IDs for discovery.',
+ {
+ query: z.string().describe('Search query — keywords to match against titles, summaries, tags, and types'),
+ },
+ async ({ query }) => {
+ const results = await store.match(query);
+ return jsonResult(results);
+ },
+ );
+
+ // ── 3. docs_get_tool_guide ────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_get_tool_guide',
+ 'Get the full operational guide for a specific tool. Returns execution model, prerequisites, result semantics, patterns, and anti-patterns.',
+ {
+ toolName: z.string().describe('Tool name (e.g. "devglide-test", "devglide-log")'),
+ },
+ async ({ toolName }) => {
+ const guide = await store.getToolGuide(toolName);
+ if (!guide) return errorResult(`No tool guide found for "${toolName}"`);
+ return jsonResult(guide);
+ },
+ );
+
+ // ── 4. docs_get_workflow ──────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_get_workflow',
+ 'Get a step-by-step workflow by name. Returns the full sequence with preflight, steps, success criteria, and failure branches.',
+ {
+ name: z.string().describe('Workflow name (e.g. "verify-ui-flow-with-devglide-test-and-devglide-log")'),
+ },
+ async ({ name }) => {
+ const workflow = await store.getWorkflow(name);
+ if (!workflow) return errorResult(`No workflow found for "${name}"`);
+ return jsonResult(workflow);
+ },
+ );
+
+ // ── 5. docs_get_troubleshooting ───────────────────────────────────────────
+
+ server.tool(
+ 'docs_get_troubleshooting',
+ 'Find troubleshooting entries by tool name and symptom. Returns likely causes, diagnosis steps, and fix instructions.',
+ {
+ toolName: z.string().describe('Tool name (e.g. "devglide-test")'),
+ symptom: z.string().describe('Symptom description (e.g. "no result found", "scenario never runs")'),
+ },
+ async ({ toolName, symptom }) => {
+ const entries = await store.getTroubleshooting(toolName, symptom);
+ if (entries.length === 0) return errorResult(`No troubleshooting entry found for "${toolName}" with symptom "${symptom}"`);
+ return jsonResult(entries);
+ },
+ );
+
+ // ── 6. docs_context ───────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_context',
+ 'Get compiled documentation as markdown for a task query. Returns the most relevant tool guides, workflows, examples, and troubleshooting bundled for LLM context injection.',
+ {
+ query: z.string().optional().describe('Task description to match relevant docs (e.g. "test club creation and verify logs"). Omit to get all docs.'),
+ projectId: z.string().optional().describe('Optional project ID to include project-specific overrides'),
+ },
+ async ({ query, projectId }) => {
+ const markdown = await store.getCompiledContext(query, projectId);
+ return {
+ content: [{ type: 'text' as const, text: markdown || 'No documentation entries found.' }],
+ };
+ },
+ );
+
+ // ── 7. docs_add ───────────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_add',
+ 'Create a new documentation entry. Provide the full content as a JSON string matching the content type schema.',
+ {
+ type: z.string().describe('Content type: tool-guide, workflow, example, troubleshooting, project-override'),
+ content: z.string().describe('JSON string with the full entry content (all fields for the chosen type)'),
+ },
+ async ({ type, content }) => {
+ let parsed: Record;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return errorResult('Invalid JSON in content field');
+ }
+
+ parsed.type = type;
+ if (!parsed.tags) parsed.tags = [];
+
+ try {
+ const entry = await store.save(parsed as any);
+ return jsonResult(entry);
+ } catch (err) {
+ return errorResult(`Validation failed: ${(err as Error).message}`);
+ }
+ },
+ );
+
+ // ── 8. docs_update ────────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_update',
+ 'Update an existing documentation entry by ID. Provide only the fields to change as a JSON string.',
+ {
+ id: z.string().describe('Entry ID'),
+ content: z.string().describe('JSON string with the fields to update'),
+ },
+ async ({ id, content }) => {
+ const existing = await store.get(id);
+ if (!existing) return errorResult('Entry not found');
+
+ let updates: Record;
+ try {
+ updates = JSON.parse(content);
+ } catch {
+ return errorResult('Invalid JSON in content field');
+ }
+
+ const merged = { ...existing, ...updates, id, type: existing.type };
+ try {
+ const entry = await store.save(merged as any);
+ return jsonResult(entry);
+ } catch (err) {
+ return errorResult(`Validation failed: ${(err as Error).message}`);
+ }
+ },
+ );
+
+ // ── 9. docs_remove ────────────────────────────────────────────────────────
+
+ server.tool(
+ 'docs_remove',
+ 'Remove a documentation entry by ID.',
+ {
+ id: z.string().describe('Entry ID'),
+ },
+ async ({ id }) => {
+ const deleted = await store.delete(id);
+ if (!deleted) return errorResult('Entry not found');
+ return jsonResult({ ok: true });
+ },
+ );
+
+ return server;
+}
diff --git a/src/apps/documentation/tsconfig.json b/src/apps/documentation/tsconfig.json
new file mode 100644
index 0000000..ef860bf
--- /dev/null
+++ b/src/apps/documentation/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../packages/tsconfig/node.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src", "mcp.ts", "types.ts", "services"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/src/apps/documentation/types.ts b/src/apps/documentation/types.ts
new file mode 100644
index 0000000..55ea812
--- /dev/null
+++ b/src/apps/documentation/types.ts
@@ -0,0 +1,99 @@
+// ── Content types ─────────────────────────────────────────────────────────────
+
+export interface ToolGuide {
+ id: string;
+ type: 'tool-guide';
+ toolName: string;
+ summary: string;
+ executionModel: string;
+ prerequisites: string[];
+ inputsExplained: Record;
+ resultSemantics: Record;
+ preferredPatterns: string[];
+ antiPatterns: string[];
+ followUpChecks: string[];
+ commonFailures: string[];
+ seeAlso: string[];
+ tags: string[];
+ projectId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DocWorkflow {
+ id: string;
+ type: 'workflow';
+ name: string;
+ goal: string;
+ toolsInvolved: string[];
+ preflight: string[];
+ stepSequence: string[];
+ successCriteria: string[];
+ failureBranches: string[];
+ expectedOutputs: string[];
+ expectedNoise: string[];
+ tags: string[];
+ projectId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DocExample {
+ id: string;
+ type: 'example';
+ toolName: string;
+ scenario: string;
+ startingAssumptions: string[];
+ toolSequence: string[];
+ whatGoodLooksLike: string[];
+ whatBadLooksLike: string[];
+ whatToDoNext: string[];
+ tags: string[];
+ projectId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Troubleshooting {
+ id: string;
+ type: 'troubleshooting';
+ toolName: string;
+ symptom: string;
+ likelyCauses: string[];
+ howToDiagnose: string[];
+ howToFix: string[];
+ whenToRetry: string;
+ tags: string[];
+ projectId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ProjectOverride {
+ id: string;
+ type: 'project-override';
+ targetToolName: string;
+ overrides: Record;
+ notes: string;
+ tags: string[];
+ projectId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+// ── Union and summary types ──────────────────────────────────────────────────
+
+export type DocEntry = ToolGuide | DocWorkflow | DocExample | Troubleshooting | ProjectOverride;
+
+export type DocType = DocEntry['type'];
+
+export interface DocSummary {
+ id: string;
+ type: DocType;
+ /** Primary label: toolName, name, or scenario depending on type */
+ title: string;
+ summary: string;
+ tags: string[];
+ scope: 'project' | 'global';
+ updatedAt: string;
+}
diff --git a/src/apps/kanban/public/favicon.svg b/src/apps/kanban/public/favicon.svg
deleted file mode 100644
index 85b3c77..0000000
--- a/src/apps/kanban/public/favicon.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/apps/kanban/public/page.css b/src/apps/kanban/public/page.css
index 1c6cbe9..fca9df2 100644
--- a/src/apps/kanban/public/page.css
+++ b/src/apps/kanban/public/page.css
@@ -1,10 +1,9 @@
+/* ── Kanban — App-specific styles ────────────────────────────────────────────── */
+/* Common primitives (page root, header, scrollbar) are in shared-ui.css. */
+
.page-kanban {
- font-family: var(--df-font-mono);
- background: var(--df-color-bg-base);
- color: var(--df-color-text-primary);
- -webkit-font-smoothing: antialiased;
- min-height: 100vh;
font-size: var(--df-font-size-sm);
+ min-height: 100vh;
overflow: hidden;
position: relative;
}
@@ -14,28 +13,6 @@
text-decoration: none;
}
-/* ── App Header ──────────────────────────────────────────────────────────── */
-
-.page-kanban .app-header {
- display: flex;
- align-items: center;
- gap: var(--df-space-2);
- height: 38px;
- padding: 0 var(--df-space-4);
- background: var(--df-color-bg-surface);
- border-bottom: 1px solid var(--df-color-border-default);
- flex-shrink: 0;
-}
-
-.page-kanban .app-name {
- font-size: var(--df-font-size-md);
- font-weight: normal;
- color: var(--df-color-accent-default);
- font-family: var(--df-font-mono);
- letter-spacing: var(--df-letter-spacing-wider);
- text-transform: uppercase;
-}
-
.page-kanban .board-edit-btn {
background: none;
border: none;
@@ -54,7 +31,7 @@
background: var(--df-bg-hover);
}
-.page-kanban .header-actions {
+.page-kanban .toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
diff --git a/src/apps/kanban/public/page.js b/src/apps/kanban/public/page.js
index 18103f5..cad0652 100644
--- a/src/apps/kanban/public/page.js
+++ b/src/apps/kanban/public/page.js
@@ -4,7 +4,10 @@
// This replaces the iframe-based page module with a fully native implementation.
// All DOM queries are scoped to `_root` (the container).
-import { escapeHtml, escapeAttr, normalizeEscapes } from '/shared-assets/ui-utils.js';
+import { escapeHtml, escapeAttr, normalizeEscapes, sanitizeHtml } from '/shared-assets/ui-utils.js';
+import { createHeader } from '/shared-ui/components/header.js';
+import { showToast as suiToast, clearToasts } from '/shared-ui/components/toast.js';
+import { confirmModal } from '/shared-ui/components/modal.js';
let _root = null;
let _projectId = null;
@@ -56,20 +59,9 @@ function apiFetch(url, options = {}) {
return fetch(url, { ...options, headers });
}
-let _toastTimer = null;
function showToast(msg, type = 'error') {
if (!_root) return;
- let toast = _root.querySelector('.toast');
- if (!toast) {
- toast = document.createElement('div');
- toast.className = 'toast';
- _root.appendChild(toast);
- }
- toast.textContent = msg;
- toast.dataset.type = type;
- toast.classList.add('visible');
- clearTimeout(_toastTimer);
- _toastTimer = setTimeout(() => toast.classList.remove('visible'), 4000);
+ suiToast(_root, msg, type);
}
// ── Scoped query helpers ─────────────────────────────────────────────────────
@@ -84,6 +76,7 @@ const FEATURE_COLORS = [
'#f59e0b', '#22c55e', '#06b6d4', '#3b82f6',
];
+// Canonical values: src/packages/shared-types/src/index.ts (KANBAN_PRIORITIES)
const PRIORITY_LABELS = { LOW: 'Low', MEDIUM: 'Medium', HIGH: 'High', URGENT: 'Urgent' };
// ── State ────────────────────────────────────────────────────────────────────
@@ -95,6 +88,7 @@ let boardPollTimer = null;
let isDragging = false;
let isDialogOpen = false;
let searchQuery = '';
+let featureSearchQuery = '';
let sortableInstances = [];
let selectedColor = FEATURE_COLORS[0];
let deleteTargetFeature = null;
@@ -128,24 +122,59 @@ function renderFeatureList() {
Board updated
-