import "dotenv/config";
import express from "express";
import { randomUUID } from "crypto";
import { spawn } from "child_process";
import { mkdir, readdir, copyFile, stat, watch, writeFile } from "fs/promises";
import { watch as fsWatch, createWriteStream } from "fs";
import path from "path";
// Store running tasks
const runningTasks = new Map();
// Store recent logs for dashboard
const recentLogs = [];
const MAX_LOGS = 100;
function log(message) {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] ${message}`;
console.log(logEntry);
recentLogs.push(logEntry);
if (recentLogs.length > MAX_LOGS) {
recentLogs.shift();
}
}
const NOTION_WORKSPACE_DIR = process.env.NOTION_WORKSPACE_DIR || "/Users/ericl/conductor/workspaces/notion-next/guangzhou-v1";
const DEFAULT_TEST_COMMAND = process.env.DEFAULT_TEST_COMMAND || "notion test";
const DEFAULT_TYPECHECK_COMMAND = process.env.DEFAULT_TYPECHECK_COMMAND || "notion typecheck --go";
const MAX_OUTPUT_CHARS = Number(process.env.MAX_OUTPUT_CHARS || 200000);
const MAX_ERROR_CHARS = Number(process.env.MAX_ERROR_CHARS || 50000);
function appendLimited(current, chunk, limit) {
if (!chunk) return current;
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
function clampText(text, limit) {
if (!text) return "";
if (text.length <= limit) return text;
return text.slice(0, limit);
}
function parseGitStatusFiles(statusOutput) {
const files = new Set();
const lines = statusOutput.split("\n").filter(Boolean);
for (const line of lines) {
if (line.length < 4) continue;
let pathPart = line.slice(3);
const arrowIndex = pathPart.indexOf(" -> ");
if (arrowIndex !== -1) {
pathPart = pathPart.slice(arrowIndex + 4);
}
const trimmed = pathPart.trim();
if (trimmed) files.add(trimmed);
}
return Array.from(files);
}
async function runShell(command, cwd, logStream, label) {
const startedAt = new Date().toISOString();
if (logStream && label) {
logStream.write(`\n[${label}] $ ${command}\n`);
}
return new Promise((resolve) => {
const proc = spawn(command, {
cwd,
shell: true,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
const text = data.toString();
stdout = appendLimited(stdout, text, MAX_OUTPUT_CHARS);
if (logStream) logStream.write(text);
});
proc.stderr.on("data", (data) => {
const text = data.toString();
stderr = appendLimited(stderr, text, MAX_ERROR_CHARS);
if (logStream) logStream.write(text);
});
proc.on("close", (code) => {
const endedAt = new Date().toISOString();
if (logStream && label) {
logStream.write(`\n[${label}] exit ${code}\n`);
}
resolve({
command,
cwd,
code,
stdout,
stderr,
startedAt,
endedAt,
});
});
});
}
function normalizeVerificationOptions(toolArgs) {
const verification = toolArgs.verification || {};
const runTests = verification.run_tests !== false;
const testCommand = verification.test_command || DEFAULT_TEST_COMMAND;
const runTypecheck = verification.run_typecheck;
const typecheckCommand = verification.typecheck_command || DEFAULT_TYPECHECK_COMMAND;
const requireChanges = verification.require_changes !== false;
return {
runTests,
testCommand,
runTypecheck,
typecheckCommand,
requireChanges,
};
}
async function verifyBuildFix(taskInfo, toolArgs, filesChanged) {
const verification = {
startedAt: new Date().toISOString(),
status: "passed",
reason: null,
filesChanged,
checks: [],
};
const options = normalizeVerificationOptions(toolArgs);
const logStream = taskInfo.verifyLogStream;
if (options.requireChanges && filesChanged.length === 0) {
verification.status = "failed";
verification.reason = "No changes detected in git status.";
}
const tsChanged = filesChanged.some((file) => /\.(ts|tsx|mts|cts)$/.test(file));
if (options.runTypecheck === undefined ? tsChanged : options.runTypecheck) {
const typecheckResult = await runShell(options.typecheckCommand, NOTION_WORKSPACE_DIR, logStream, "typecheck");
verification.checks.push({
name: "typecheck",
command: options.typecheckCommand,
exitCode: typecheckResult.code,
stdout: clampText(typecheckResult.stdout, 2000),
stderr: clampText(typecheckResult.stderr, 2000),
});
if (typecheckResult.code !== 0 && verification.status === "passed") {
verification.status = "failed";
verification.reason = "Typecheck failed.";
}
} else {
verification.checks.push({
name: "typecheck",
command: options.typecheckCommand,
exitCode: null,
stdout: "skipped",
stderr: "",
});
}
if (options.runTests && options.testCommand) {
const testResult = await runShell(options.testCommand, NOTION_WORKSPACE_DIR, logStream, "tests");
verification.checks.push({
name: "tests",
command: options.testCommand,
exitCode: testResult.code,
stdout: clampText(testResult.stdout, 2000),
stderr: clampText(testResult.stderr, 2000),
});
if (testResult.code !== 0 && verification.status === "passed") {
verification.status = "failed";
verification.reason = "Tests failed.";
}
} else {
verification.checks.push({
name: "tests",
command: options.testCommand,
exitCode: null,
stdout: "skipped",
stderr: "",
});
}
const summaryParts = [];
summaryParts.push(`Changes: ${filesChanged.length}`);
const testCheck = verification.checks.find((check) => check.name === "tests");
if (testCheck?.exitCode === 0) summaryParts.push("Tests: passed");
if (testCheck?.exitCode && testCheck.exitCode !== 0) summaryParts.push("Tests: failed");
if (testCheck?.exitCode === null) summaryParts.push("Tests: skipped");
const typecheckCheck = verification.checks.find((check) => check.name === "typecheck");
if (typecheckCheck?.exitCode === 0) summaryParts.push("Typecheck: passed");
if (typecheckCheck?.exitCode && typecheckCheck.exitCode !== 0) summaryParts.push("Typecheck: failed");
if (typecheckCheck?.exitCode === null) summaryParts.push("Typecheck: skipped");
verification.summary = summaryParts.join(" • ");
verification.endedAt = new Date().toISOString();
return verification;
}
// Demo output directory
const DEMOS_DIR = "/Users/ericl/conductor/workspaces/feedback-pipeline-mcp/lahore/.context/feedback-pipeline/demos";
// Playwright screenshots directory - where Playwright MCP actually saves screenshots
// The Playwright MCP server saves screenshots relative to the workspace, in a .playwright-mcp folder
const PLAYWRIGHT_SCREENSHOTS_DIR = path.join(NOTION_WORKSPACE_DIR, ".playwright-mcp");
// Notion API configuration for callbacks
const NOTION_API_TOKEN = process.env.NOTION_API_TOKEN;
const NOTION_API_BASE = "https://api.notion.com/v1";
/**
* Add a comment to a Notion page using the Comments API.
* This is used to notify the user when a task completes (since bot-created threads
* don't trigger notifications, but page comments do).
*/
async function addPageComment(pageId, comment) {
if (!NOTION_API_TOKEN) {
console.log("[COMMENT] No NOTION_API_TOKEN set, skipping page comment");
return null;
}
if (!pageId) {
console.log("[COMMENT] No page_id provided, skipping comment");
return null;
}
// Extract page ID from URL if a full URL was provided
// e.g., "https://dev.notion.so/2f6b4835f58b80a58de6c21f9d793022" -> "2f6b4835f58b80a58de6c21f9d793022"
// or "https://www.notion.so/workspace/Page-Title-2f6b4835f58b80a58de6c21f9d793022" -> "2f6b4835f58b80a58de6c21f9d793022"
let extractedPageId = pageId;
if (pageId.includes("notion.so")) {
// Try to extract the 32-character hex ID from the URL
const match = pageId.match(/([a-f0-9]{32})(?:\?|$|#)/i) || pageId.match(/([a-f0-9]{32})/i);
if (match) {
extractedPageId = match[1];
console.log(`[COMMENT] Extracted page ID from URL: ${extractedPageId}`);
} else {
console.log(`[COMMENT] Could not extract page ID from URL: ${pageId}`);
return null;
}
}
// Convert to UUID format if needed (add hyphens: 8-4-4-4-12)
if (extractedPageId.length === 32 && !extractedPageId.includes("-")) {
extractedPageId = `${extractedPageId.slice(0,8)}-${extractedPageId.slice(8,12)}-${extractedPageId.slice(12,16)}-${extractedPageId.slice(16,20)}-${extractedPageId.slice(20)}`;
console.log(`[COMMENT] Formatted page ID with hyphens: ${extractedPageId}`);
}
console.log(`[COMMENT] Adding comment to page ${extractedPageId}`);
try {
const commentBody = {
parent: { page_id: extractedPageId },
rich_text: [{ type: "text", text: { content: comment } }],
};
console.log(`[COMMENT] POST ${NOTION_API_BASE}/comments`);
console.log(`[COMMENT] Body:`, JSON.stringify(commentBody).substring(0, 500));
const response = await fetch(`${NOTION_API_BASE}/comments`, {
method: "POST",
headers: {
"Authorization": `Bearer ${NOTION_API_TOKEN}`,
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
body: JSON.stringify(commentBody),
});
const responseText = await response.text();
console.log(`[COMMENT] Response status: ${response.status}`);
console.log(`[COMMENT] Response body: ${responseText.substring(0, 500)}`);
const result = JSON.parse(responseText);
// Check for error in response body (Notion sometimes returns 200 with error in body)
if (!response.ok || result.object === "error") {
console.error(`[COMMENT] Failed to add comment: ${response.status} ${result.message || responseText}`);
return null;
}
console.log(`[COMMENT] Successfully added comment to page ${extractedPageId}`);
return result;
} catch (error) {
console.error(`[COMMENT] Error adding comment:`, error);
return null;
}
}
/**
* Format page comment for task completion - includes links to demo and chat thread.
*/
function formatPageComment(taskId, taskInfo, demoUrl, threadId) {
const status = taskInfo.status === "completed" ? "✅ Task completed" : "❌ Task failed";
const type = taskInfo.type === "reproduce_bug" ? "Bug reproduction" : "Build fix";
let comment = `${status}: ${type}\n`;
comment += `Demo: ${demoUrl}\n`;
if (threadId) {
comment += `Continue chat: Open "All chats" in Bug Wizard to find the thread\n`;
}
// Add brief summary
if (taskInfo.output) {
const lines = taskInfo.output.split('\n').filter(l => l.trim());
const summary = lines.slice(-3).join('\n').substring(0, 300);
if (summary) {
comment += `\nSummary:\n${summary}`;
}
}
return comment;
}
/**
* Send a message to a Notion agent thread using the Agents SDK API.
* This is used to notify the custom agent when a task completes.
*/
async function notifyAgent(agentId, threadId, message) {
if (!NOTION_API_TOKEN) {
console.log("[CALLBACK] No NOTION_API_TOKEN set, skipping agent notification");
return null;
}
if (!agentId) {
console.log("[CALLBACK] No agent_id provided, skipping notification");
return null;
}
console.log(`[CALLBACK] Notifying agent ${agentId} on thread ${threadId || "(new thread)"}`);
console.log(`[CALLBACK] threadId type: ${typeof threadId}, value: "${threadId}"`);
try {
const body = { message };
if (threadId) {
// Extract UUID from thread URL if needed
// e.g., "thread://184b4835-f58b-810e-a836-0003d4da627e/2f6b4835-f58b-8199-8855-00a9e8c6f220"
// -> extract the second UUID (the actual thread ID)
let extractedThreadId = threadId;
if (threadId.startsWith("thread://")) {
const parts = threadId.replace("thread://", "").split("/");
if (parts.length >= 2) {
extractedThreadId = parts[1]; // Use the second part (thread ID, not agent ID)
console.log(`[CALLBACK] Extracted thread ID from URL: ${extractedThreadId}`);
}
}
// Only add thread_id if it looks like a valid UUID
if (extractedThreadId && /^[a-f0-9-]{36}$/i.test(extractedThreadId)) {
body.thread_id = extractedThreadId;
} else {
console.log(`[CALLBACK] Invalid thread ID format, will create new thread: ${extractedThreadId}`);
}
}
console.log(`[CALLBACK] Request body keys:`, Object.keys(body));
const url = `${NOTION_API_BASE}/agents/${agentId}/chat`;
console.log(`[CALLBACK] POST ${url}`);
console.log(`[CALLBACK] Body:`, JSON.stringify(body).substring(0, 500));
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${NOTION_API_TOKEN}`,
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
body: JSON.stringify(body),
});
const responseText = await response.text();
console.log(`[CALLBACK] Response status: ${response.status}`);
console.log(`[CALLBACK] Response body: ${responseText.substring(0, 500)}`);
const result = JSON.parse(responseText);
// Check for error in response body (Notion sometimes returns 200 with error in body)
if (!response.ok || result.object === "error") {
console.error(`[CALLBACK] Failed to notify agent: ${response.status} ${result.message || responseText}`);
return null;
}
console.log(`[CALLBACK] Successfully notified agent. Thread: ${result.thread_id}`);
return result;
} catch (error) {
console.error(`[CALLBACK] Error notifying agent:`, error);
return null;
}
}
/**
* Format task results for the callback message to the agent.
*/
function formatTaskResultMessage(taskId, taskInfo, demoUrl) {
const status = taskInfo.status === "completed" ? "✅ COMPLETED" : "❌ FAILED";
const type = taskInfo.type === "reproduce_bug" ? "Bug Reproduction" : "Build Fix";
let message = `**${type} ${status}**\n\n`;
message += `**Task ID:** ${taskId}\n`;
message += `**Duration:** ${taskInfo.startTime} → ${taskInfo.endTime}\n`;
if (taskInfo.exitCode !== 0) {
message += `**Exit Code:** ${taskInfo.exitCode}\n`;
}
message += `\n**Demo URL:** ${demoUrl}\n`;
// Add summary from output (first 2000 chars)
if (taskInfo.output) {
const summary = taskInfo.output.substring(0, 2000);
message += `\n**Summary:**\n${summary}`;
if (taskInfo.output.length > 2000) {
message += "\n... (truncated)";
}
}
if (taskInfo.status === "failed" && taskInfo.errorOutput) {
message += `\n\n**Errors:**\n${taskInfo.errorOutput.substring(0, 500)}`;
}
return message;
}
/**
* Format task results with verification info for build_fix tasks.
*/
function formatTaskResultMessageWithVerification(taskId, taskInfo, demoUrl) {
const status = taskInfo.status === "completed" ? "✅ COMPLETED" : "❌ FAILED";
const type = taskInfo.type === "reproduce_bug" ? "Bug Reproduction" : "Build Fix";
let message = `**${type} ${status}**\n\n`;
message += `**Task ID:** ${taskId}\n`;
message += `**Branch:** ${taskInfo.branchName || "N/A"}\n`;
message += `**Duration:** ${taskInfo.startTime} → ${taskInfo.endTime}\n`;
// Add verification results
if (taskInfo.verification) {
const v = taskInfo.verification;
message += `\n**Verification:** ${v.status === "passed" ? "✅ Passed" : "❌ Failed"}\n`;
message += `${v.summary}\n`;
if (v.reason) {
message += `Reason: ${v.reason}\n`;
}
if (v.filesChanged && v.filesChanged.length > 0) {
message += `Files changed: ${v.filesChanged.slice(0, 10).join(", ")}${v.filesChanged.length > 10 ? "..." : ""}\n`;
}
}
if (taskInfo.failureReason) {
message += `\n**Failure:** ${taskInfo.failureReason}\n`;
}
message += `\n**Demo URL:** ${demoUrl}\n`;
message += `**Logs:** ${demoUrl}claude.log | ${demoUrl}verify.log\n`;
// Add summary from output (first 1500 chars to leave room for verification)
if (taskInfo.output) {
const summary = taskInfo.output.substring(0, 1500);
message += `\n**Claude Output:**\n${summary}`;
if (taskInfo.output.length > 1500) {
message += "\n... (truncated)";
}
}
return message;
}
/**
* Format page comment with verification results for build_fix tasks.
*/
function formatPageCommentWithVerification(taskId, taskInfo, demoUrl, threadId) {
const status = taskInfo.status === "completed" ? "✅ Task completed" : "❌ Task failed";
const type = taskInfo.type === "reproduce_bug" ? "Bug reproduction" : "Build fix";
let comment = `${status}: ${type}\n`;
comment += `Branch: ${taskInfo.branchName || "N/A"}\n`;
comment += `Demo: ${demoUrl}\n`;
// Add verification summary
if (taskInfo.verification) {
const v = taskInfo.verification;
comment += `\nVerification: ${v.summary}\n`;
if (v.reason && taskInfo.status === "failed") {
comment += `Failure: ${v.reason}\n`;
}
}
if (threadId) {
comment += `\nContinue chat: Open "All chats" in Bug Wizard to find the thread\n`;
}
// Add brief summary from output
if (taskInfo.output) {
const lines = taskInfo.output.split('\n').filter(l => l.trim());
const summary = lines.slice(-3).join('\n').substring(0, 250);
if (summary) {
comment += `\nSummary:\n${summary}`;
}
}
return comment;
}
const app = express();
// Store sessions with their response streams
const sessions = new Map();
// Parse JSON for POST requests
app.use(express.json());
// Serve demo files statically
app.use("/demos", express.static(DEMOS_DIR));
// Log requests
app.use((req, res, next) => {
log(`${req.method} ${req.path}${req.url.includes('?') ? '?' + req.url.split('?')[1] : ''}`);
next();
});
// Ensure directories exist
await mkdir(DEMOS_DIR, { recursive: true });
await mkdir(PLAYWRIGHT_SCREENSHOTS_DIR, { recursive: true });
/**
* Watch for new screenshots in the Playwright output directory and copy them
* to the active task's demo directory. Returns a cleanup function to stop watching.
*/
function watchForScreenshots(taskId, demoDir) {
const copiedFiles = new Set();
let checkInterval;
const copyNewScreenshots = async () => {
try {
const files = await readdir(PLAYWRIGHT_SCREENSHOTS_DIR);
const pngFiles = files.filter(f => f.endsWith('.png') || f.endsWith('.jpg') || f.endsWith('.jpeg'));
for (const file of pngFiles) {
if (!copiedFiles.has(file)) {
const srcPath = path.join(PLAYWRIGHT_SCREENSHOTS_DIR, file);
const destPath = path.join(demoDir, file);
try {
const fileStat = await stat(srcPath);
// Only copy files modified in the last 5 minutes (likely from current task)
if (Date.now() - fileStat.mtimeMs < 5 * 60 * 1000) {
await copyFile(srcPath, destPath);
copiedFiles.add(file);
log(`[SCREENSHOT] Copied ${file} to ${taskId.substring(0,8)}`);
}
} catch (err) {
// File might be in use, try again later
}
}
}
} catch (err) {
// Directory might not exist yet
}
};
// Check for new screenshots every 2 seconds
checkInterval = setInterval(copyNewScreenshots, 2000);
// Initial check
copyNewScreenshots();
// Return cleanup function
return () => {
if (checkInterval) clearInterval(checkInterval);
};
}
// Handle SSE connection at root (Notion's expected pattern)
app.get("/", async (req, res) => {
// If it's an SSE request, handle MCP protocol
if (req.headers.accept?.includes("text/event-stream")) {
console.log("[SSE] New connection");
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
res.flushHeaders();
const sessionId = randomUUID();
sessions.set(sessionId, { res, initialized: false });
log(`[SSE] Session created: ${sessionId}`);
res.write(`event: endpoint\ndata: /?sessionId=${sessionId}\n\n`);
res.on("close", () => {
log(`[SSE] Session closed: ${sessionId}`);
sessions.delete(sessionId);
});
const keepAlive = setInterval(() => {
if (sessions.has(sessionId)) {
res.write(": keepalive\n\n");
} else {
clearInterval(keepAlive);
}
}, 30000);
return;
}
// If browser request, serve dashboard
if (req.headers.accept?.includes("text/html")) {
// Get active tasks from memory (only running/verifying tasks)
const activeTasks = [];
const completedTasks = [];
for (const [taskId, info] of runningTasks) {
const taskData = {
id: taskId,
type: info.type,
status: info.status,
description: info.bugDescription || info.description,
startTime: info.startTime,
endTime: info.endTime,
demoUrl: info.demoUrl || `/demos/${taskId}/`,
};
if (info.status === "running" || info.status === "verifying") {
activeTasks.push(taskData);
} else {
completedTasks.push(taskData);
}
}
// Use activeTasks for the "Active Tasks" section
const tasks = activeTasks;
// Get all demos from disk
let demos = [];
try {
const demoFolders = await readdir(DEMOS_DIR);
for (const folder of demoFolders) {
const folderPath = path.join(DEMOS_DIR, folder);
const folderStat = await stat(folderPath);
if (folderStat.isDirectory()) {
// Count screenshots
let screenshotCount = 0;
try {
const files = await readdir(folderPath);
screenshotCount = files.filter(f => f.endsWith('.png') || f.endsWith('.jpg')).length;
} catch {}
// Check if task is actually running (not just exists)
const taskInfo = runningTasks.get(folder);
const isRunning = taskInfo && (taskInfo.status === "running" || taskInfo.status === "verifying");
demos.push({
id: folder,
created: folderStat.mtime,
screenshotCount,
isActive: isRunning,
status: taskInfo?.status || "archived",
});
}
}
// Sort by most recent first
demos.sort((a, b) => b.created - a.created);
} catch {}
const html = `<!DOCTYPE html>
<html>
<head>
<title>Feedback Pipeline MCP</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
h1 { color: #fff; margin-bottom: 5px; }
.subtitle { color: #888; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
.stats {
display: flex;
gap: 15px;
margin-bottom: 25px;
flex-wrap: wrap;
}
.stat {
background: #0d0d1a;
border: 1px solid #333;
border-radius: 8px;
padding: 12px 20px;
text-align: center;
flex: 1;
min-width: 80px;
}
.stat-value { font-size: 24px; font-weight: bold; color: #fff; }
.stat-label { font-size: 11px; color: #888; text-transform: uppercase; }
.section { margin: 20px 0; }
.section h2 { color: #fff; font-size: 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.card {
background: #0d0d1a;
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
}
.task-list, .demo-list, .route-list { display: flex; flex-direction: column; gap: 8px; }
.task, .demo-item {
background: #16162a;
border: 1px solid #2a2a4a;
border-radius: 6px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.task:hover, .demo-item:hover { border-color: #444; }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.running { background: #f39c12; animation: pulse 1s infinite; }
.status-dot.completed { background: #27ae60; }
.status-dot.failed { background: #e74c3c; }
.status-dot.archived { background: #666; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.item-info { flex: 1; min-width: 0; }
.item-title {
color: #fff;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta { font-size: 11px; color: #666; margin-top: 3px; }
.btn {
background: #333;
color: #fff;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.btn:hover { background: #444; }
.btn-primary { background: #3498db; }
.btn-primary:hover { background: #2980b9; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.empty {
text-align: center;
padding: 30px;
color: #555;
font-size: 13px;
}
.route-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #2a2a4a;
}
.route-item:last-child { border-bottom: none; }
.route-path { font-family: monospace; color: #3498db; font-size: 13px; }
.route-desc { color: #888; font-size: 12px; }
.badge {
background: #333;
color: #888;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin-left: 6px;
}
.badge.active { background: #f39c12; color: #000; }
</style>
</head>
<body>
<h1>Feedback Pipeline MCP</h1>
<p class="subtitle">Claude Code Bridge Server running on port ${PORT}</p>
<div class="stats">
<div class="stat">
<div class="stat-value">${tasks.filter(t => t.status === 'running').length}</div>
<div class="stat-label">Running</div>
</div>
<div class="stat">
<div class="stat-value">${tasks.filter(t => t.status === 'completed').length}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat">
<div class="stat-value">${demos.length}</div>
<div class="stat-label">Demos</div>
</div>
<div class="stat">
<div class="stat-value">${sessions.size}</div>
<div class="stat-label">MCP Sessions</div>
</div>
</div>
<div class="grid">
<div>
<div class="section">
<h2>Active Tasks</h2>
<div class="card">
<div class="task-list">
${tasks.length === 0 ? '<div class="empty">No active tasks</div>' : ''}
${tasks.map(task => `
<div class="task">
<div class="status-dot ${task.status}"></div>
<div class="item-info">
<div class="item-title">${(task.description || task.id.substring(0, 8)).substring(0, 80)}${(task.description || '').length > 80 ? '...' : ''}</div>
<div class="item-meta">${task.type === 'reproduce_bug' ? 'Bug Reproduction' : 'Build Fix'} • ${task.status}</div>
</div>
${(task.status === 'running' || task.status === 'verifying') ? `<button class="btn btn-danger" onclick="killTask('${task.id}')">Kill</button>` : ''}
<a href="${task.demoUrl}" class="btn btn-primary">View</a>
</div>
`).join('')}
</div>
</div>
</div>
<div class="section">
<h2>All Demos (${demos.length})</h2>
<div class="card">
<div class="demo-list">
${demos.length === 0 ? '<div class="empty">No demos yet</div>' : ''}
${demos.map(demo => `
<div class="demo-item">
<div class="status-dot ${demo.status === 'running' || demo.status === 'verifying' ? 'running' : demo.status === 'completed' ? 'completed' : demo.status === 'failed' ? 'failed' : 'archived'}"></div>
<div class="item-info">
<div class="item-title">${demo.id.substring(0, 8)}...${demo.isActive ? '<span class="badge active">LIVE</span>' : demo.status === 'completed' ? '<span class="badge" style="background:#27ae60;color:#fff;">DONE</span>' : demo.status === 'failed' ? '<span class="badge" style="background:#e74c3c;color:#fff;">FAILED</span>' : ''}</div>
<div class="item-meta">${demo.screenshotCount} screenshots • ${demo.created.toLocaleString()}</div>
</div>
<a href="/demos/${demo.id}/" class="btn btn-primary">View</a>
</div>
`).join('')}
</div>
</div>
</div>
</div>
<div>
<div class="section">
<h2>API Routes</h2>
<div class="card">
<div class="route-list">
<div class="route-item">
<div>
<div class="route-path">GET /</div>
<div class="route-desc">This dashboard (HTML) or MCP SSE endpoint</div>
</div>
<a href="/" class="btn">Open</a>
</div>
<div class="route-item">
<div>
<div class="route-path">GET /health</div>
<div class="route-desc">Health check endpoint</div>
</div>
<a href="/health" class="btn">Open</a>
</div>
<div class="route-item">
<div>
<div class="route-path">GET /tasks</div>
<div class="route-desc">List all active tasks (JSON)</div>
</div>
<a href="/tasks" class="btn">Open</a>
</div>
<div class="route-item">
<div>
<div class="route-path">GET /demos</div>
<div class="route-desc">Browse all demo recordings</div>
</div>
<a href="/demos" class="btn">Open</a>
</div>
<div class="route-item">
<div>
<div class="route-path">GET /test/simple</div>
<div class="route-desc">Test Claude spawn</div>
</div>
<a href="/test/simple" class="btn">Run</a>
</div>
<div class="route-item">
<div>
<div class="route-path">POST /test/reproduce_bug</div>
<div class="route-desc">Test bug reproduction directly</div>
</div>
<span class="btn" style="opacity:0.5">POST</span>
</div>
</div>
</div>
</div>
<div class="section">
<h2>MCP Tools</h2>
<div class="card">
<div class="route-list">
<div class="route-item">
<div>
<div class="route-path">ping</div>
<div class="route-desc">Test MCP connectivity</div>
</div>
</div>
<div class="route-item">
<div>
<div class="route-path">reproduce_bug</div>
<div class="route-desc">Reproduce a bug with Playwright</div>
</div>
</div>
<div class="route-item">
<div>
<div class="route-path">build_fix</div>
<div class="route-desc">Build a fix or feature</div>
</div>
</div>
<div class="route-item">
<div>
<div class="route-path">get_task_status</div>
<div class="route-desc">Check task progress</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top: 30px;">
<h2>Server Logs</h2>
<div class="card">
<div id="logs" style="font-family: monospace; font-size: 12px; max-height: 300px; overflow-y: auto; background: #0a0a15; padding: 10px; border-radius: 4px; white-space: pre-wrap; word-break: break-all;">
${recentLogs.slice(-30).map(l => `<div style="color: #888; border-bottom: 1px solid #1a1a2e; padding: 2px 0;">${l.replace(/</g, '<').replace(/>/g, '>')}</div>`).join('')}
</div>
</div>
</div>
<script>
// Auto-scroll logs to bottom
const logsEl = document.getElementById('logs');
logsEl.scrollTop = logsEl.scrollHeight;
// Kill a task
async function killTask(taskId) {
if (!confirm('Are you sure you want to kill this task?')) return;
try {
const res = await fetch('/tasks/' + taskId + '/kill', { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('Task killed');
location.reload();
} else {
alert('Failed to kill task: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Error: ' + err.message);
}
}
// Refresh page every 5 seconds
setTimeout(() => location.reload(), 5000);
</script>
</body>
</html>`;
return res.send(html);
}
// Default JSON response for API clients
return res.json({ name: "claude-code-bridge", status: "ok" });
});
// Handle MCP messages via POST
app.post("/", async (req, res) => {
const sessionId = req.query.sessionId;
const session = sessions.get(sessionId);
if (!session) {
console.log(`[POST] No session: ${sessionId}`);
return res.status(400).json({ error: "Invalid session" });
}
const message = req.body;
console.log(`[POST] Message for ${sessionId}:`, message.method);
try {
// Handle MCP JSON-RPC messages
if (message.method === "initialize") {
const response = {
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2025-06-18",
serverInfo: { name: "claude-code-bridge", version: "1.0.0" },
capabilities: { tools: {} },
},
};
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
session.initialized = true;
res.status(202).json({ status: "accepted" });
} else if (message.method === "notifications/initialized") {
res.status(202).json({ status: "accepted" });
} else if (message.method === "tools/list") {
const response = {
jsonrpc: "2.0",
id: message.id,
result: {
tools: [
{
name: "ping",
description: "Test connectivity - returns pong",
inputSchema: { type: "object", properties: {} },
},
{
name: "reproduce_bug",
description: "Reproduce a bug using Playwright browser automation. This starts the Notion dev server, uses Claude Code with Playwright MCP to reproduce the bug, and captures screenshots of the reproduction. IMPORTANT: Always pass callback_agent_id, callback_thread_id, and callback_page_id so you receive a notification when the task completes.",
inputSchema: {
type: "object",
properties: {
bug_description: {
type: "string",
description: "Detailed description of the bug to reproduce"
},
reproduction_steps: {
type: "string",
description: "Step-by-step instructions on how to reproduce the bug (optional - Claude will figure it out if not provided)"
},
callback_agent_id: {
type: "string",
description: "REQUIRED: Your agent ID (2f5b4835f58b8068ba3500927783d4bc) - results will be sent back to you when complete"
},
callback_thread_id: {
type: "string",
description: "REQUIRED: The current thread ID you are chatting in - results will be posted to this thread so the user gets notified"
},
callback_page_id: {
type: "string",
description: "REQUIRED: The page ID of the task/feedback item in Notion - a comment will be added with results and demo link"
},
},
required: ["bug_description", "callback_agent_id", "callback_thread_id", "callback_page_id"],
},
},
{
name: "build_fix",
description: "Build a fix or feature using Claude Code. Creates a branch, implements the changes, runs tests, and optionally verifies with Playwright. IMPORTANT: Always pass callback_agent_id, callback_thread_id, and callback_page_id so you receive a notification when the task completes.",
inputSchema: {
type: "object",
properties: {
description: {
type: "string",
description: "What to build or fix - be specific about the desired outcome"
},
plan: {
type: "string",
description: "The implementation plan (optional - Claude will create one if not provided)"
},
verify_with_playwright: {
type: "boolean",
description: "Whether to verify the fix works using Playwright browser automation (default: true)"
},
callback_agent_id: {
type: "string",
description: "REQUIRED: Your agent ID (2f5b4835f58b8068ba3500927783d4bc) - results will be sent back to you when complete"
},
callback_thread_id: {
type: "string",
description: "REQUIRED: The current thread ID you are chatting in - results will be posted to this thread so the user gets notified"
},
callback_page_id: {
type: "string",
description: "REQUIRED: The page ID of the task/feedback item in Notion - a comment will be added with results and demo link"
},
},
required: ["description", "callback_agent_id", "callback_thread_id", "callback_page_id"],
},
},
{
name: "get_task_status",
description: "Check the status and output of a running or completed task",
inputSchema: {
type: "object",
properties: {
task_id: { type: "string", description: "The task ID returned by reproduce_bug or build_fix" },
},
required: ["task_id"],
},
},
],
},
};
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
res.status(202).json({ status: "accepted" });
} else if (message.method === "tools/call") {
const toolName = message.params?.name;
const toolArgs = message.params?.arguments || {};
log(`[TOOL] Calling ${toolName}`);
let result;
if (toolName === "ping") {
result = { content: [{ type: "text", text: "pong - connection successful!" }] };
} else if (toolName === "reproduce_bug") {
const taskId = randomUUID();
const videoDir = path.join(DEMOS_DIR, taskId);
await mkdir(videoDir, { recursive: true });
// Accept either bug_description or description for flexibility
const bugDescription = toolArgs.bug_description || toolArgs.description;
const reproSteps = toolArgs.reproduction_steps || toolArgs.steps || "";
// Callback info for notifying the agent when complete
const callbackAgentId = toolArgs.callback_agent_id;
const callbackThreadId = toolArgs.callback_thread_id;
const callbackPageId = toolArgs.callback_page_id;
const prompt = `You are reproducing a bug in the Notion app.
BUG DESCRIPTION:
${bugDescription}
${reproSteps ? `REPRODUCTION STEPS:\n${reproSteps}\n` : ""}
YOUR TASK:
1. First, check if the dev server is running on port 3000 with: notion ai check-server-ready - If not running, start it: notion run
- Wait for it to be ready before proceeding
2. Use Playwright MCP to reproduce this bug:
- Navigate to localhost:3000
- Follow the local-debugging-playwright skill for login (email: test@gmail.com, code: test)
- Reproduce the bug step by step
- Take screenshots at key moments using browser_take_screenshot
3. When done, summarize:
- Whether the bug was successfully reproduced
- What you observed
- The screenshots you captured
IMPORTANT: Use the Playwright MCP tools (browser_navigate, browser_click, browser_type, browser_snapshot, browser_take_screenshot).
For screenshots, use descriptive filenames like "step-1-login.png", "step-2-bug-visible.png", etc. Screenshots will be automatically collected.`;
log(`[TASK] Starting reproduce_bug ${taskId}`);
const claudeProcess = spawn("claude", ["--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "-p", prompt], {
cwd: "/Users/ericl/conductor/workspaces/notion-next/guangzhou-v1",
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let output = "";
let errorOutput = "";
let humanReadableOutput = "";
const demoUrl = `http://localhost:4000/demos/${taskId}/`;
// Start watching for screenshots from Playwright
const stopWatching = watchForScreenshots(taskId, videoDir);
// Store task info BEFORE setting up handlers so we can update it incrementally
const taskInfoObj = {
type: "reproduce_bug",
status: "running",
bugDescription,
videoDir,
startTime: new Date().toISOString(),
output: "",
errorOutput: "",
process: claudeProcess,
callbackAgentId,
callbackThreadId,
callbackPageId,
demoUrl,
stopWatching,
};
runningTasks.set(taskId, taskInfoObj);
log(`[TASK ${taskId.substring(0,8)}] Callback info - agentId: ${callbackAgentId}, threadId: ${callbackThreadId}, pageId: ${callbackPageId}`);
// Parse streaming JSON output from Claude
let jsonBuffer = "";
claudeProcess.stdout.on("data", (data) => {
const chunk = data.toString();
output += chunk;
jsonBuffer += chunk;
// Process complete JSON lines
const lines = jsonBuffer.split('\n');
jsonBuffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
// Extract human-readable content from different event types
if (event.type === "assistant" && event.message?.content) {
for (const block of event.message.content) {
if (block.type === "text") {
humanReadableOutput += block.text + "\n";
} else if (block.type === "tool_use") {
humanReadableOutput += `[Tool: ${block.name}]\n`;
}
}
} else if (event.type === "content_block_delta" && event.delta?.text) {
humanReadableOutput += event.delta.text;
} else if (event.type === "result") {
if (event.result) {
humanReadableOutput += `\n[Result: ${event.result}]\n`;
}
}
} catch (e) {
// Not valid JSON, might be partial or raw text
humanReadableOutput += line + "\n";
}
}
taskInfoObj.output = humanReadableOutput; // Update live for streaming
console.log(`[TASK ${taskId.substring(0,8)}]`, humanReadableOutput.slice(-200));
});
claudeProcess.stderr.on("data", (data) => {
errorOutput += data.toString();
taskInfoObj.errorOutput = errorOutput; // Update live for streaming
console.log(`[TASK ${taskId}] stderr:`, data.toString().substring(0, 200));
});
claudeProcess.on("close", async (code) => {
const taskInfo = runningTasks.get(taskId);
if (taskInfo) {
taskInfo.status = code === 0 ? "completed" : "failed";
taskInfo.exitCode = code;
// Keep human-readable output (don't overwrite with raw JSON)
taskInfo.output = humanReadableOutput;
taskInfo.errorOutput = errorOutput;
taskInfo.endTime = new Date().toISOString();
log(`[TASK ${taskId.substring(0,8)}] Completed with code ${code}`);
// Stop watching for screenshots after a brief delay to catch any final ones
setTimeout(() => {
if (taskInfo.stopWatching) taskInfo.stopWatching();
}, 5000);
// Notify the agent if callback info was provided
let notifyResult = null;
if (taskInfo.callbackAgentId) {
const callbackMessage = formatTaskResultMessage(taskId, taskInfo, taskInfo.demoUrl);
notifyResult = await notifyAgent(taskInfo.callbackAgentId, taskInfo.callbackThreadId, callbackMessage);
}
// Add a comment to the page with demo link and thread reference
if (taskInfo.callbackPageId) {
const threadId = notifyResult?.thread_id || taskInfo.callbackThreadId;
const pageComment = formatPageComment(taskId, taskInfo, taskInfo.demoUrl, threadId);
await addPageComment(taskInfo.callbackPageId, pageComment);
}
}
});
result = {
content: [{
type: "text",
text: JSON.stringify({
task_id: taskId,
type: "reproduce_bug",
status: "started",
message: callbackAgentId
? "Bug reproduction started. You will be notified when complete with screenshots and results."
: "Bug reproduction started. Use get_task_status to check progress. Screenshots will be saved and a demo URL provided when complete.",
demo_url: demoUrl,
will_callback: !!callbackAgentId,
will_comment: !!callbackPageId,
}),
}],
};
} else if (toolName === "build_fix") {
const taskId = randomUUID();
const videoDir = path.join(DEMOS_DIR, taskId);
await mkdir(videoDir, { recursive: true });
const description = toolArgs.description;
const plan = toolArgs.plan || "";
const verifyWithPlaywright = toolArgs.verify_with_playwright !== false;
// Callback info for notifying the agent when complete
const callbackAgentId = toolArgs.callback_agent_id;
const callbackThreadId = toolArgs.callback_thread_id;
const callbackPageId = toolArgs.callback_page_id;
const prompt = `You are building a fix/feature for the Notion app.
DESCRIPTION:
${description}
${plan ? `IMPLEMENTATION PLAN:\n${plan}\n` : ""}
YOUR TASK:
1. Create a new git branch for this work: git checkout -b fix/${taskId.substring(0, 8)}
2. Implement the fix/feature:
- Search the codebase to understand the relevant code
- Make the necessary changes
- Follow existing code patterns and style
3. Run relevant tests: notion test <relevant-test-files>
4. Run typecheck: notion typecheck --go (if you made TypeScript changes)
${verifyWithPlaywright ? `5. Verify the fix works using Playwright:
- Check dev server on port 3000: notion ai check-server-ready - If not running, start it: notion run
- Navigate to localhost:3000 and use Playwright MCP to verify the fix
- Take screenshots showing it works using browser_take_screenshot
- Use descriptive filenames like "verification-1.png", "verification-2.png", etc.` : ""}
6. When done, provide a summary:
- Branch name
- Files changed
- What was fixed/built
- Test results
- ${verifyWithPlaywright ? "Screenshots captured showing the fix works" : ""}
IMPORTANT:
- Make minimal, focused changes
- Don't over-engineer
- Follow the existing codebase patterns`;
log(`[TASK] Starting build_fix ${taskId}`);
const claudeProcess = spawn("claude", ["--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "-p", prompt], {
cwd: "/Users/ericl/conductor/workspaces/notion-next/guangzhou-v1",
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let output = "";
let errorOutput = "";
let humanReadableOutput = "";
const demoUrl = verifyWithPlaywright ? `http://localhost:4000/demos/${taskId}/` : null;
const branchName = `fix/${taskId.substring(0, 8)}`;
// Start watching for screenshots from Playwright (if verifying)
const stopWatching = verifyWithPlaywright ? watchForScreenshots(taskId, videoDir) : null;
// Create log files for debugging
const claudeLogPath = path.join(videoDir, "claude.log");
const verifyLogPath = path.join(videoDir, "verify.log");
const claudeLogStream = createWriteStream(claudeLogPath, { flags: "a" });
const verifyLogStream = createWriteStream(verifyLogPath, { flags: "a" });
// Store task info BEFORE setting up handlers so we can update it incrementally
const taskInfoObj = {
type: "build_fix",
status: "running",
description,
videoDir,
branchName,
startTime: new Date().toISOString(),
output: "",
errorOutput: "",
process: claudeProcess,
callbackAgentId,
callbackThreadId,
callbackPageId,
demoUrl,
stopWatching,
claudeLogStream,
verifyLogStream,
claudeLogPath,
verifyLogPath,
toolArgs,
};
runningTasks.set(taskId, taskInfoObj);
// Parse streaming JSON output from Claude
let jsonBuffer = "";
claudeProcess.stdout.on("data", (data) => {
const chunk = data.toString();
output += chunk;
jsonBuffer += chunk;
// Write raw output to log file
claudeLogStream.write(chunk);
// Process complete JSON lines
const lines = jsonBuffer.split('\n');
jsonBuffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
// Extract human-readable content from different event types
if (event.type === "assistant" && event.message?.content) {
for (const block of event.message.content) {
if (block.type === "text") {
humanReadableOutput += block.text + "\n";
} else if (block.type === "tool_use") {
humanReadableOutput += `[Tool: ${block.name}]\n`;
}
}
} else if (event.type === "content_block_delta" && event.delta?.text) {
humanReadableOutput += event.delta.text;
} else if (event.type === "result") {
if (event.result) {
humanReadableOutput += `\n[Result: ${event.result}]\n`;
}
}
} catch (e) {
// Not valid JSON, might be partial or raw text
humanReadableOutput += line + "\n";
}
}
taskInfoObj.output = humanReadableOutput; // Update live for streaming
console.log(`[TASK ${taskId.substring(0,8)}]`, humanReadableOutput.slice(-200));
});
claudeProcess.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
claudeLogStream.write(`[STDERR] ${text}`);
taskInfoObj.errorOutput = errorOutput; // Update live for streaming
console.log(`[TASK ${taskId}] stderr:`, text.substring(0, 200));
});
claudeProcess.on("close", async (code) => {
const taskInfo = runningTasks.get(taskId);
if (taskInfo) {
// Close Claude log stream
claudeLogStream.end();
// Keep human-readable output (don't overwrite with raw JSON)
taskInfo.exitCode = code;
taskInfo.output = humanReadableOutput;
taskInfo.errorOutput = errorOutput;
taskInfo.endTime = new Date().toISOString();
log(`[TASK ${taskId.substring(0,8)}] Claude finished with code ${code}`);
// Run verification if Claude succeeded (or always for debugging)
let verification = null;
if (code === 0 || true) { // Always run verification to capture state
log(`[TASK ${taskId.substring(0,8)}] Running verification...`);
taskInfo.status = "verifying";
try {
// Get changed files from git status
const gitStatusResult = await runShell(
"git status --porcelain",
NOTION_WORKSPACE_DIR,
verifyLogStream,
"git-status"
);
const filesChanged = parseGitStatusFiles(gitStatusResult.stdout);
// Run verification checks
verification = await verifyBuildFix(
{ ...taskInfo, verifyLogStream },
taskInfo.toolArgs || {},
filesChanged
);
taskInfo.verification = verification;
log(`[TASK ${taskId.substring(0,8)}] Verification: ${verification.status} - ${verification.summary}`);
// Write verification summary to log
verifyLogStream.write(`\n\n=== VERIFICATION SUMMARY ===\n`);
verifyLogStream.write(`Status: ${verification.status}\n`);
verifyLogStream.write(`Reason: ${verification.reason || "All checks passed"}\n`);
verifyLogStream.write(`Summary: ${verification.summary}\n`);
verifyLogStream.write(`Files changed: ${filesChanged.join(", ") || "none"}\n`);
// Determine final status based on Claude exit code AND verification
if (code === 0 && verification.status === "passed") {
taskInfo.status = "completed";
} else {
taskInfo.status = "failed";
if (code !== 0) {
taskInfo.failureReason = `Claude exited with code ${code}`;
} else {
taskInfo.failureReason = verification.reason;
}
}
} catch (verifyError) {
log(`[TASK ${taskId.substring(0,8)}] Verification error: ${verifyError.message}`);
verifyLogStream.write(`\n[ERROR] ${verifyError.message}\n`);
taskInfo.status = code === 0 ? "completed" : "failed";
taskInfo.verification = { status: "error", reason: verifyError.message };
}
} else {
taskInfo.status = "failed";
taskInfo.failureReason = `Claude exited with code ${code}`;
}
// Close verify log stream
verifyLogStream.end();
log(`[TASK ${taskId.substring(0,8)}] Final status: ${taskInfo.status}`);
// Stop watching for screenshots after a brief delay to catch any final ones
setTimeout(() => {
if (taskInfo.stopWatching) taskInfo.stopWatching();
}, 5000);
// Notify the agent if callback info was provided
let notifyResult = null;
if (taskInfo.callbackAgentId) {
const callbackMessage = formatTaskResultMessageWithVerification(taskId, taskInfo, taskInfo.demoUrl || "N/A");
notifyResult = await notifyAgent(taskInfo.callbackAgentId, taskInfo.callbackThreadId, callbackMessage);
}
// Add a comment to the page with demo link and thread reference
if (taskInfo.callbackPageId) {
const threadId = notifyResult?.thread_id || taskInfo.callbackThreadId;
const pageComment = formatPageCommentWithVerification(taskId, taskInfo, taskInfo.demoUrl || "N/A", threadId);
await addPageComment(taskInfo.callbackPageId, pageComment);
}
}
});
result = {
content: [{
type: "text",
text: JSON.stringify({
task_id: taskId,
type: "build_fix",
status: "started",
branch: branchName,
message: callbackAgentId
? "Build started. You will be notified when complete with results and any verification screenshots."
: "Build started. Use get_task_status to check progress.",
demo_url: demoUrl,
logs: {
claude: `${demoUrl}claude.log`,
verify: `${demoUrl}verify.log`,
},
will_callback: !!callbackAgentId,
will_comment: !!callbackPageId,
}),
}],
};
} else if (toolName === "get_task_status") {
const taskId = toolArgs.task_id;
const taskInfo = runningTasks.get(taskId);
if (!taskInfo) {
result = {
content: [{
type: "text",
text: JSON.stringify({ error: "Task not found", task_id: taskId }),
}],
isError: true,
};
} else {
const demoUrl = taskInfo.videoDir ? `http://localhost:4000/demos/${taskId}/` : null;
result = {
content: [{
type: "text",
text: JSON.stringify({
task_id: taskId,
type: taskInfo.type,
status: taskInfo.status,
branchName: taskInfo.branchName,
startTime: taskInfo.startTime,
endTime: taskInfo.endTime,
exitCode: taskInfo.exitCode,
failureReason: taskInfo.failureReason,
verification: taskInfo.verification ? {
status: taskInfo.verification.status,
summary: taskInfo.verification.summary,
reason: taskInfo.verification.reason,
filesChanged: taskInfo.verification.filesChanged,
} : null,
output: taskInfo.output?.substring(0, 10000) || "",
errorOutput: taskInfo.errorOutput?.substring(0, 2000) || "",
demo_url: demoUrl,
logs: demoUrl ? {
claude: `${demoUrl}claude.log`,
verify: `${demoUrl}verify.log`,
} : null,
}),
}],
};
}
} else {
result = { content: [{ type: "text", text: `Unknown tool: ${toolName}` }], isError: true };
}
const response = { jsonrpc: "2.0", id: message.id, result };
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
res.status(202).json({ status: "accepted" });
} else if (message.method === "resources/list") {
const response = {
jsonrpc: "2.0",
id: message.id,
result: { resources: [] },
};
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
res.status(202).json({ status: "accepted" });
} else if (message.method === "prompts/list") {
const response = {
jsonrpc: "2.0",
id: message.id,
result: { prompts: [] },
};
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
res.status(202).json({ status: "accepted" });
} else {
console.log(`[POST] Unknown method: ${message.method}`);
const response = {
jsonrpc: "2.0",
id: message.id,
result: {},
};
session.res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
res.status(202).json({ status: "accepted" });
}
} catch (error) {
console.error(`[POST] Error:`, error);
res.status(500).json({ error: error.message });
}
});
// Health check
app.get("/health", (req, res) => {
res.json({ status: "ok", sessions: sessions.size, tasks: runningTasks.size });
});
// List all tasks with their status
app.get("/tasks", (req, res) => {
const tasks = [];
for (const [taskId, info] of runningTasks) {
tasks.push({
task_id: taskId,
type: info.type,
status: info.status,
startTime: info.startTime,
endTime: info.endTime,
exitCode: info.exitCode,
description: info.bugDescription || info.description,
output_preview: info.output?.substring(0, 500) || "",
demo_url: `http://localhost:4000/demos/${taskId}/`,
});
}
res.json({ tasks });
});
// Get single task details
app.get("/tasks/:taskId", (req, res) => {
const taskId = req.params.taskId;
const info = runningTasks.get(taskId);
if (!info) {
return res.status(404).json({ error: "Task not found" });
}
const demoUrl = `http://localhost:4000/demos/${taskId}/`;
res.json({
task_id: taskId,
type: info.type,
status: info.status,
branchName: info.branchName,
startTime: info.startTime,
endTime: info.endTime,
exitCode: info.exitCode,
failureReason: info.failureReason,
description: info.bugDescription || info.description,
verification: info.verification ? {
status: info.verification.status,
summary: info.verification.summary,
reason: info.verification.reason,
filesChanged: info.verification.filesChanged,
checks: info.verification.checks,
} : null,
output: info.output || "",
errorOutput: info.errorOutput || "",
demo_url: demoUrl,
logs: {
claude: `${demoUrl}claude.log`,
verify: `${demoUrl}verify.log`,
},
});
});
// Kill a running task
app.post("/tasks/:taskId/kill", (req, res) => {
const taskId = req.params.taskId;
const info = runningTasks.get(taskId);
if (!info) {
return res.status(404).json({ error: "Task not found" });
}
if (info.status !== "running" && info.status !== "verifying") {
return res.status(400).json({ error: "Task is not running", status: info.status });
}
log(`[KILL] Killing task ${taskId.substring(0, 8)}`);
try {
// Kill the process and its children (negative PID kills process group)
if (info.process && info.process.pid) {
process.kill(-info.process.pid, "SIGTERM");
}
// Update task status
info.status = "failed";
info.exitCode = -1;
info.failureReason = "Killed by user";
info.endTime = new Date().toISOString();
// Stop screenshot watcher
if (info.stopWatching) info.stopWatching();
// Close log streams
if (info.claudeLogStream) info.claudeLogStream.end();
if (info.verifyLogStream) info.verifyLogStream.end();
res.json({ success: true, message: "Task killed" });
} catch (err) {
log(`[KILL] Error killing task: ${err.message}`);
res.status(500).json({ error: err.message });
}
});
// List demos as HTML
app.get("/demos", async (req, res) => {
try {
const demos = await readdir(DEMOS_DIR);
const html = `<!DOCTYPE html>
<html><head><title>Demos</title></head>
<body>
<h1>Demo Recordings</h1>
<ul>
${demos.map(d => `<li><a href="/demos/${d}/">${d}</a></li>`).join('\n')}
</ul>
</body></html>`;
res.send(html);
} catch {
res.send("<h1>No demos yet</h1>");
}
});
// Live progress page with SSE streaming
app.get("/demos/:taskId/", async (req, res) => {
const taskId = req.params.taskId;
const taskInfo = runningTasks.get(taskId);
// Serve the live progress HTML page
const html = `<!DOCTYPE html>
<html>
<head>
<title>Task Progress - ${taskId.substring(0, 8)}</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
h1 { color: #fff; margin-bottom: 5px; }
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-weight: bold;
margin-bottom: 20px;
}
.status.running { background: #f39c12; color: #000; }
.status.completed { background: #27ae60; color: #fff; }
.status.failed { background: #e74c3c; color: #fff; }
.meta { color: #888; margin-bottom: 20px; }
.section { margin: 20px 0; }
.section h2 {
color: #fff;
font-size: 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.section h2 .dot {
width: 8px;
height: 8px;
background: #27ae60;
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.log-container {
background: #0d0d1a;
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
max-height: 400px;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.screenshots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.screenshot {
background: #0d0d1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
}
.screenshot img {
width: 100%;
display: block;
cursor: pointer;
transition: opacity 0.2s;
}
.screenshot img:hover {
opacity: 0.8;
}
.screenshot .label {
padding: 10px;
font-size: 12px;
color: #888;
}
.empty { color: #666; font-style: italic; }
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #333;
border-top-color: #f39c12;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<h1>🔍 Task Progress</h1>
<div class="meta">Task ID: ${taskId}</div>
<div id="status" class="status running"><span class="spinner"></span>Starting...</div>
<div class="section">
<h2><span class="dot" id="live-dot"></span> Live Output</h2>
<div id="logs" class="log-container">Waiting for output...</div>
</div>
<div class="section">
<h2>📸 Screenshots</h2>
<div id="screenshots" class="screenshots">
<div class="empty">Screenshots will appear here as they're captured...</div>
</div>
</div>
<script>
const taskId = "${taskId}";
const logsEl = document.getElementById('logs');
const statusEl = document.getElementById('status');
const screenshotsEl = document.getElementById('screenshots');
const liveDot = document.getElementById('live-dot');
let lastOutput = '';
let seenScreenshots = new Set();
// Connect to SSE stream for live updates
const eventSource = new EventSource('/progress/' + taskId + '/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Update status
statusEl.className = 'status ' + data.status;
if (data.status === 'running') {
statusEl.innerHTML = '<span class="spinner"></span>' + (data.description || 'Running...');
} else if (data.status === 'verifying') {
statusEl.innerHTML = '<span class="spinner"></span>Verifying (typecheck, tests)...';
} else if (data.status === 'completed') {
let statusText = '✅ Completed';
if (data.verification) {
statusText += ' • ' + data.verification.summary;
}
statusEl.textContent = statusText;
liveDot.style.display = 'none';
eventSource.close();
} else if (data.status === 'failed') {
let statusText = '❌ Failed';
if (data.failureReason) {
statusText += ': ' + data.failureReason;
} else if (data.exitCode) {
statusText += ' (exit code: ' + data.exitCode + ')';
}
if (data.verification && data.verification.summary) {
statusText += ' • ' + data.verification.summary;
}
statusEl.textContent = statusText;
liveDot.style.display = 'none';
eventSource.close();
}
// Update logs (only append new content)
if (data.output && data.output !== lastOutput) {
logsEl.textContent = data.output || 'No output yet...';
logsEl.scrollTop = logsEl.scrollHeight;
lastOutput = data.output;
}
// Update screenshots
if (data.screenshots && data.screenshots.length > 0) {
const newScreenshots = data.screenshots.filter(s => !seenScreenshots.has(s));
if (newScreenshots.length > 0 || seenScreenshots.size === 0) {
screenshotsEl.innerHTML = data.screenshots.map(s => {
seenScreenshots.add(s);
return '<div class="screenshot"><a href="' + s + '" target="_blank"><img src="' + s + '?t=' + Date.now() + '" /></a><div class="label">' + s + '</div></div>';
}).join('');
}
}
};
eventSource.onerror = () => {
// Connection closed, task probably done
liveDot.style.display = 'none';
};
</script>
</body>
</html>`;
res.send(html);
});
// SSE endpoint for streaming task progress
app.get("/progress/:taskId/stream", async (req, res) => {
const taskId = req.params.taskId;
const taskInfo = runningTasks.get(taskId);
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
res.flushHeaders();
// Send initial state
const sendUpdate = async () => {
const info = runningTasks.get(taskId);
if (!info) {
res.write('data: {"status": "not_found"}\n\n');
res.end();
return false;
}
// Get current screenshots
let screenshots = [];
try {
const files = await readdir(info.videoDir);
screenshots = files
.filter(f => f.endsWith('.png') || f.endsWith('.jpg'))
.sort();
} catch {}
const update = {
status: info.status,
description: info.bugDescription || info.description,
branchName: info.branchName,
output: info.output || "",
errorOutput: info.errorOutput || "",
exitCode: info.exitCode,
failureReason: info.failureReason,
startTime: info.startTime,
endTime: info.endTime,
verification: info.verification ? {
status: info.verification.status,
summary: info.verification.summary,
reason: info.verification.reason,
} : null,
screenshots,
};
res.write(`data: ${JSON.stringify(update)}\n\n`);
// Continue streaming while running or verifying
return info.status === "running" || info.status === "verifying";
};
// Send updates every second while running
const interval = setInterval(async () => {
const stillRunning = await sendUpdate();
if (!stillRunning) {
clearInterval(interval);
res.end();
}
}, 1000);
// Send initial update immediately
await sendUpdate();
// Clean up on client disconnect
res.on("close", () => {
clearInterval(interval);
});
});
// Simple test endpoint
app.get("/test/simple", async (req, res) => {
const taskId = randomUUID();
log(`[SIMPLE TEST] Starting ${taskId.substring(0,8)}`);
const claudeProcess = spawn("claude", ["--dangerously-skip-permissions", "--print", "-p", "Say hello and exit"], {
cwd: "/Users/ericl/conductor/workspaces/notion-next/guangzhou-v1",
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
let errorOutput = "";
claudeProcess.stdout.on("data", (data) => {
const text = data.toString();
output += text;
console.log(`[SIMPLE ${taskId}] STDOUT:`, text.substring(0, 200));
});
claudeProcess.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
console.log(`[SIMPLE ${taskId}] STDERR:`, text.substring(0, 200));
});
claudeProcess.on("error", (err) => {
console.log(`[SIMPLE ${taskId}] ERROR:`, err);
});
claudeProcess.on("close", (code, signal) => {
console.log(`[SIMPLE ${taskId}] CLOSED: code=${code}, signal=${signal}`);
console.log(`[SIMPLE ${taskId}] OUTPUT:`, output.substring(0, 500));
});
res.json({ task_id: taskId, message: "Check server logs" });
});
// TEST ENDPOINTS - bypass MCP for direct testing
app.post("/test/reproduce_bug", async (req, res) => {
const { description, steps, callback_agent_id, callback_thread_id } = req.body;
const taskId = randomUUID();
const videoDir = path.join(DEMOS_DIR, taskId);
await mkdir(videoDir, { recursive: true });
const demoUrl = `http://localhost:4000/demos/${taskId}/`;
const prompt = `You are reproducing a bug in the Notion app.
BUG DESCRIPTION:
${description}
${steps ? `REPRODUCTION STEPS:\n${steps}\n` : ""}
YOUR TASK:
1. First, check if the dev server is running on port 3000 with: notion ai check-server-ready - If not running, start it: notion run
- Wait for it to be ready before proceeding
2. Use Playwright MCP to reproduce this bug:
- Navigate to localhost:3000
- Follow the local-debugging-playwright skill for login (email: test@gmail.com, code: test)
- Reproduce the bug step by step
- Take screenshots at key moments using browser_take_screenshot
3. When done, summarize:
- Whether the bug was successfully reproduced
- What you observed
- The screenshots you captured
IMPORTANT: Use the Playwright MCP tools (browser_navigate, browser_click, browser_type, browser_snapshot, browser_take_screenshot).
For screenshots, use descriptive filenames like "step-1-login.png", "step-2-bug-visible.png", etc. Screenshots will be automatically collected.`;
log(`[TEST] Starting reproduce_bug ${taskId.substring(0,8)}`);
console.log(`[TEST] Description: ${description}`);
const claudeProcess = spawn("claude", ["--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose", "-p", prompt], {
cwd: "/Users/ericl/conductor/workspaces/notion-next/guangzhou-v1",
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
let errorOutput = "";
let humanReadableOutput = "";
// Start watching for screenshots from Playwright
const stopWatching = watchForScreenshots(taskId, videoDir);
// Store task info BEFORE setting up handlers so we can update it incrementally
const taskInfoObj = {
type: "reproduce_bug",
status: "running",
bugDescription: description,
videoDir,
startTime: new Date().toISOString(),
output: "",
errorOutput: "",
process: claudeProcess,
callbackAgentId: callback_agent_id,
callbackThreadId: callback_thread_id,
demoUrl,
stopWatching,
};
runningTasks.set(taskId, taskInfoObj);
// Parse streaming JSON output from Claude
let jsonBuffer = "";
claudeProcess.stdout.on("data", (data) => {
const chunk = data.toString();
output += chunk;
jsonBuffer += chunk;
// Process complete JSON lines
const lines = jsonBuffer.split('\n');
jsonBuffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
// Extract human-readable content from different event types
if (event.type === "assistant" && event.message?.content) {
for (const block of event.message.content) {
if (block.type === "text") {
humanReadableOutput += block.text + "\n";
} else if (block.type === "tool_use") {
humanReadableOutput += `[Tool: ${block.name}]\n`;
}
}
} else if (event.type === "content_block_delta" && event.delta?.text) {
humanReadableOutput += event.delta.text;
} else if (event.type === "result") {
if (event.result) {
humanReadableOutput += `\n[Result: ${event.result}]\n`;
}
}
} catch (e) {
// Not valid JSON, might be partial or raw text
humanReadableOutput += line + "\n";
}
}
taskInfoObj.output = humanReadableOutput; // Update live for streaming
console.log(`[TASK ${taskId.substring(0,8)}]`, humanReadableOutput.slice(-200));
});
claudeProcess.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
taskInfoObj.errorOutput = errorOutput; // Update live for streaming
console.error(`[TASK ${taskId} ERR]`, text);
});
claudeProcess.on("close", async (code) => {
const taskInfo = runningTasks.get(taskId);
if (taskInfo) {
taskInfo.status = code === 0 ? "completed" : "failed";
taskInfo.exitCode = code;
// Keep human-readable output (don't overwrite with raw JSON)
taskInfo.output = humanReadableOutput;
taskInfo.errorOutput = errorOutput;
taskInfo.endTime = new Date().toISOString();
log(`[TASK ${taskId.substring(0,8)}] Completed with code ${code}`);
// Stop watching for screenshots after a brief delay to catch any final ones
setTimeout(() => {
if (taskInfo.stopWatching) taskInfo.stopWatching();
}, 5000);
// Notify the agent if callback info was provided
if (taskInfo.callbackAgentId) {
const callbackMessage = formatTaskResultMessage(taskId, taskInfo, taskInfo.demoUrl);
await notifyAgent(taskInfo.callbackAgentId, taskInfo.callbackThreadId, callbackMessage);
}
}
});
res.json({
task_id: taskId,
status: "started",
demo_url: `http://localhost:4000/demos/${taskId}/`,
monitor_url: `http://localhost:4000/tasks/${taskId}`,
logs: "tail -f /tmp/mcp-server.log",
});
});
// CORS
app.options("*", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "*");
res.sendStatus(204);
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`
========================================
Claude Code Bridge MCP Server
========================================
http://localhost:${PORT}
Tools:
- ping: Test connectivity
- reproduce_bug: Reproduce a bug with Playwright, capture screenshots
- build_fix: Build a fix/feature, optionally verify with Playwright
- get_task_status: Check task progress/output
Callbacks:
- NOTION_API_TOKEN: ${NOTION_API_TOKEN ? "✓ configured" : "✗ not set (callbacks disabled)"}
Demos served at: http://localhost:${PORT}/demos/
========================================
`);
});