diff --git a/chat/reports.mjs b/chat/reports.mjs new file mode 100644 index 0000000..08b2fb6 --- /dev/null +++ b/chat/reports.mjs @@ -0,0 +1,210 @@ +import { randomBytes } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, copyFileSync, renameSync } from 'fs'; +import { dirname, join } from 'path'; +import { REPORTS_DIR, REPORTS_META_FILE } from '../lib/config.mjs'; + +// ---- Persistence ---- + +function loadMeta() { + try { + if (!existsSync(REPORTS_META_FILE)) return []; + return JSON.parse(readFileSync(REPORTS_META_FILE, 'utf8')); + } catch { + return []; + } +} + +function saveMeta(reports) { + const dir = dirname(REPORTS_META_FILE); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const tmp = REPORTS_META_FILE + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(reports, null, 2), 'utf8'); + renameSync(tmp, REPORTS_META_FILE); +} + +function ensureReportsDir() { + if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true }); +} + +// ---- HTML Validation (hard test) ---- + +/** + * Validate an HTML file before accepting it as a report. + * Throws descriptive errors if validation fails. + */ +function validateHtml(filePath) { + const html = readFileSync(filePath, 'utf-8'); + + // Check 1: File not empty + if (!html.trim()) { + throw new Error('HTML file is empty'); + } + + // Check 2: Basic HTML structure tags + if (!/<(html|body|div|article|section|h[1-6]|p|table|ul|ol)/i.test(html)) { + throw new Error('HTML file lacks basic structure tags (expected at least one of: html, body, div, article, section, h1-h6, p, table, ul, ol)'); + } + + // Check 3: Simple tag balance check for critical tags + const tagErrors = checkTagBalance(html); + if (tagErrors.length > 0) { + throw new Error(`HTML structure errors:\n${tagErrors.join('\n')}`); + } + + // Check 4: Minimum text content + const textContent = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); + if (textContent.length < 50) { + throw new Error(`HTML content too short (${textContent.length} chars of text, minimum 50)`); + } + + return { charCount: html.length, textLength: textContent.length }; +} + +/** + * Simple tag balance checker. Returns an array of error strings. + * Only checks block-level / important tags — not self-closing or inline. + */ +function checkTagBalance(html) { + const errors = []; + // Remove comments, scripts, styles, and self-closing tags before checking + let cleaned = html + .replace(//g, '') + .replace(//gi, '') + .replace(//gi, ''); + + const BLOCK_TAGS = ['html', 'head', 'body', 'div', 'article', 'section', 'nav', 'header', 'footer', 'main', 'table', 'thead', 'tbody', 'tr', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'form', 'fieldset', 'details', 'summary']; + const SELF_CLOSING = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'col', 'area', 'base', 'source', 'track', 'wbr', 'embed']); + + const stack = []; + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g; + let match; + + while ((match = tagRegex.exec(cleaned)) !== null) { + const full = match[0]; + const tagName = match[1].toLowerCase(); + + if (!BLOCK_TAGS.includes(tagName)) continue; + if (SELF_CLOSING.has(tagName)) continue; + if (full.endsWith('/>')) continue; // self-closing syntax + + const isClosing = full.startsWith(' with no matching opening tag`); + } else if (stack[stack.length - 1] === tagName) { + stack.pop(); + } else { + // Mismatch — try to find matching open tag + const idx = stack.lastIndexOf(tagName); + if (idx !== -1) { + const unclosed = stack.splice(idx); + unclosed.pop(); // remove the matched one + for (const t of unclosed) { + errors.push(`Unclosed tag <${t}> before `); + } + } else { + errors.push(`Unexpected closing tag (expected or no closing tag)`); + } + } + } else { + stack.push(tagName); + } + } + + // Only report top-level unclosed tags (limit noise) + if (stack.length > 0 && stack.length <= 5) { + for (const t of stack) { + errors.push(`Unclosed tag <${t}>`); + } + } else if (stack.length > 5) { + errors.push(`${stack.length} unclosed tags detected (first: <${stack[0]}>)`); + } + + return errors; +} + +// ---- Public API ---- + +export function listReports() { + return loadMeta().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); +} + +export function getReport(id) { + const reports = loadMeta(); + return reports.find(r => r.id === id) || null; +} + +export function getReportHtml(id) { + const report = getReport(id); + if (!report) return null; + const filePath = join(REPORTS_DIR, report.filename); + if (!existsSync(filePath)) return null; + return readFileSync(filePath, 'utf-8'); +} + +/** + * Create a new report. Validates the HTML, copies the file, stores metadata. + * Throws on validation failure with detailed error messages. + */ +export function createReport({ title, filePath, sessionId, sessionFolder, source }) { + if (!title) throw new Error('title is required'); + if (!filePath) throw new Error('filePath is required'); + if (!existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + + // Hard test: validate HTML + const stats = validateHtml(filePath); + console.log(`[reports] HTML validation passed: ${stats.charCount} chars, ${stats.textLength} text chars`); + + // Copy file to reports directory + ensureReportsDir(); + const id = randomBytes(16).toString('hex'); + const filename = `${id}.html`; + const destPath = join(REPORTS_DIR, filename); + copyFileSync(filePath, destPath); + + const report = { + id, + title, + filename, + sessionId: sessionId || null, + sessionFolder: sessionFolder || null, + source: source || 'unknown', + createdAt: new Date().toISOString(), + read: false, + }; + + const reports = loadMeta(); + reports.push(report); + saveMeta(reports); + + console.log(`[reports] Created report "${title}" id=${id.slice(0, 8)} source=${source}`); + return report; +} + +export function markAsRead(id) { + const reports = loadMeta(); + const report = reports.find(r => r.id === id); + if (!report) return null; + report.read = true; + saveMeta(reports); + return report; +} + +export function deleteReport(id) { + const reports = loadMeta(); + const idx = reports.findIndex(r => r.id === id); + if (idx === -1) return false; + const report = reports[idx]; + // Delete HTML file + const filePath = join(REPORTS_DIR, report.filename); + try { unlinkSync(filePath); } catch {} + // Remove from metadata + reports.splice(idx, 1); + saveMeta(reports); + console.log(`[reports] Deleted report id=${id.slice(0, 8)}`); + return true; +} + +export function getUnreadCount() { + return loadMeta().filter(r => !r.read).length; +} diff --git a/chat/router-control-routes.mjs b/chat/router-control-routes.mjs index 64539eb..9538f43 100644 --- a/chat/router-control-routes.mjs +++ b/chat/router-control-routes.mjs @@ -1,8 +1,15 @@ import { readFile, readdir } from 'fs/promises'; +import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs'; import { homedir } from 'os'; import { basename, dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; import { CHAT_IMAGES_DIR, FILE_ASSET_STORAGE_PROVIDER } from '../lib/config.mjs'; +import { createTask, getTask, listTasks, updateTask, deleteTask } from './task-manager.mjs'; +import { createReport, listReports, getReport, getReportHtml, markAsRead, deleteReport } from './reports.mjs'; +import { updateLastRun, reloadSchedule } from './scheduler.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); import { saveUiRuntimeSelection } from '../lib/runtime-selection.mjs'; import { getAvailableToolsAsync, saveSimpleToolAsync } from '../lib/tools.mjs'; import { readBody } from '../lib/utils.mjs'; @@ -31,7 +38,9 @@ import { localizeFileAsset, } from './file-assets.mjs'; import { createShareSnapshot } from './shares.mjs'; -import { pathExists } from './fs-utils.mjs'; +import { + pathExists, +} from './fs-utils.mjs'; import { applyTemplateToSession, appendAssistantMessage, @@ -906,5 +915,261 @@ export async function handleControlRoutes({ return true; } + // ---- Internal Report Submission (MCP submit_report) ---- + + if (pathname === '/api/internal/report' && req.method === 'POST') { + let body; + try { body = await readBody(req, 65536); } catch { body = '{}'; } + let data; + try { data = JSON.parse(body); } catch { + writeJson(res, 400, { error: 'Invalid JSON' }); + return true; + } + const { title, file_path: filePath, session_id: sessionId, source } = data; + try { + const session = sessionId ? await getSession(sessionId) : null; + const report = createReport({ + title, + filePath, + sessionId, + sessionFolder: session?.folder || null, + source: source || 'unknown', + }); + writeJson(res, 201, { success: true, reportId: report.id, report }); + } catch (err) { + writeJson(res, 400, { error: err.message }); + } + return true; + } + + // ---- Reports API ---- + + if (pathname === '/api/reports' && req.method === 'GET') { + writeJson(res, 200, listReports()); + return true; + } + + const reportMatch = pathname.match(/^\/api\/reports\/([a-f0-9]+)(\/(\w+))?$/); + if (reportMatch) { + const id = reportMatch[1]; + const sub = reportMatch[3]; + + if (!sub && req.method === 'GET') { + const report = getReport(id); + if (!report) { + writeJson(res, 404, { error: 'Report not found' }); + return true; + } + writeJson(res, 200, report); + return true; + } + + if (sub === 'html' && req.method === 'GET') { + const html = getReportHtml(id); + if (html === null) { + writeJson(res, 404, { error: 'Report not found' }); + return true; + } + const injected = html.replace(/(]*>)/i, '$1'); + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; img-src 'self' data: blob:", + }); + res.end(injected); + return true; + } + + if (sub === 'read' && req.method === 'PATCH') { + const updated = markAsRead(id); + if (!updated) { + writeJson(res, 404, { error: 'Report not found' }); + return true; + } + writeJson(res, 200, updated); + return true; + } + + if (!sub && req.method === 'DELETE') { + const ok = deleteReport(id); + if (!ok) { + writeJson(res, 404, { error: 'Report not found' }); + return true; + } + writeJson(res, 200, { ok: true }); + return true; + } + } + + // ---- Schedules API ---- + + if (pathname === '/api/schedules' && req.method === 'GET') { + try { + const schedulesFile = join(__dirname, '..', 'workflows', 'schedules.json'); + const data = existsSync(schedulesFile) + ? JSON.parse(readFileSync(schedulesFile, 'utf8')) + : { schedules: [] }; + writeJson(res, 200, data); + } catch (err) { + writeJson(res, 500, { error: err.message }); + } + return true; + } + + const scheduleTriggerMatch = pathname.match(/^\/api\/schedules\/([^/]+)\/trigger$/); + if (scheduleTriggerMatch && req.method === 'POST') { + const scheduleId = scheduleTriggerMatch[1]; + try { + const schedulesFile = join(__dirname, '..', 'workflows', 'schedules.json'); + const data = existsSync(schedulesFile) + ? JSON.parse(readFileSync(schedulesFile, 'utf8')) + : { schedules: [] }; + const schedule = data.schedules.find(s => s.id === scheduleId); + if (!schedule) { + writeJson(res, 404, { error: 'Schedule not found' }); + return true; + } + updateLastRun(scheduleId); + writeJson(res, 202, { ok: true, workflow: schedule.workflow, status: 'triggered' }); + } catch (err) { + writeJson(res, 500, { error: err.message }); + } + return true; + } + + const scheduleReloadMatch = pathname.match(/^\/api\/schedules\/([^/]+)\/reload$/); + if (scheduleReloadMatch && req.method === 'POST') { + reloadSchedule(scheduleReloadMatch[1]); + writeJson(res, 200, { ok: true }); + return true; + } + + const schedulePatchMatch = pathname.match(/^\/api\/schedules\/([^/]+)$/); + if (schedulePatchMatch && req.method === 'PATCH') { + const scheduleId = schedulePatchMatch[1]; + let body; + try { body = await readBody(req, 4096); } catch { body = '{}'; } + try { + const updates = JSON.parse(body); + const schedulesFile = join(__dirname, '..', 'workflows', 'schedules.json'); + const data = existsSync(schedulesFile) + ? JSON.parse(readFileSync(schedulesFile, 'utf8')) + : { schedules: [] }; + const schedule = data.schedules.find(s => s.id === scheduleId); + if (!schedule) { + writeJson(res, 404, { error: 'Schedule not found' }); + return true; + } + const ALLOWED = ['enabled', 'maxRuns', 'disposable', 'intervalMs']; + for (const key of ALLOWED) { + if (updates[key] !== undefined) schedule[key] = updates[key]; + } + const tmp = schedulesFile + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, schedulesFile); + if (updates.enabled !== undefined) { + reloadSchedule(scheduleId); + } + writeJson(res, 200, { schedule }); + } catch (err) { + writeJson(res, 400, { error: err.message || 'Invalid request body' }); + } + return true; + } + + if (schedulePatchMatch && req.method === 'DELETE') { + const scheduleId = schedulePatchMatch[1]; + try { + const schedulesFile = join(__dirname, '..', 'workflows', 'schedules.json'); + const data = existsSync(schedulesFile) + ? JSON.parse(readFileSync(schedulesFile, 'utf8')) + : { schedules: [] }; + const idx = data.schedules.findIndex(s => s.id === scheduleId); + if (idx === -1) { + writeJson(res, 404, { error: 'Schedule not found' }); + return true; + } + data.schedules.splice(idx, 1); + const tmp = schedulesFile + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, schedulesFile); + reloadSchedule(scheduleId); + writeJson(res, 200, { ok: true }); + } catch (err) { + writeJson(res, 500, { error: err.message }); + } + return true; + } + + // ---- Task API ---- + + if (pathname === '/api/tasks' && req.method === 'GET') { + const filters = {}; + if (parsedUrl.query?.status) filters.status = parsedUrl.query.status; + if (parsedUrl.query?.assigned_session_id) filters.assigned_session_id = parsedUrl.query.assigned_session_id; + writeJson(res, 200, { tasks: listTasks(filters) }); + return true; + } + + if (pathname === '/api/tasks' && req.method === 'POST') { + let body; + try { body = JSON.parse(await readBody(req, 16384)); } catch { + writeJson(res, 400, { error: 'Invalid JSON' }); + return true; + } + try { + const task = createTask({ + subject: body.subject, + description: body.description, + assigned_session_id: body.assigned_session_id, + blocked_by: body.blocked_by, + report_to: body.report_to, + repo_path: body.repo_path, + worktree_path: body.worktree_path, + branch: body.branch, + }); + writeJson(res, 201, { task }); + } catch (err) { + writeJson(res, 400, { error: err.message }); + } + return true; + } + + const taskIdMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/); + if (taskIdMatch && req.method === 'GET') { + const task = getTask(taskIdMatch[1]); + if (!task) { + writeJson(res, 404, { error: 'Task not found' }); + return true; + } + writeJson(res, 200, { task }); + return true; + } + + if (taskIdMatch && req.method === 'PATCH') { + let body; + try { body = JSON.parse(await readBody(req, 16384)); } catch { + writeJson(res, 400, { error: 'Invalid JSON' }); + return true; + } + const task = updateTask(taskIdMatch[1], body); + if (!task) { + writeJson(res, 404, { error: 'Task not found' }); + return true; + } + writeJson(res, 200, { task }); + return true; + } + + if (taskIdMatch && req.method === 'DELETE') { + const deleted = deleteTask(taskIdMatch[1]); + if (!deleted) { + writeJson(res, 404, { error: 'Task not found' }); + return true; + } + writeJson(res, 200, { ok: true }); + return true; + } + return false; } diff --git a/chat/scheduler.mjs b/chat/scheduler.mjs new file mode 100644 index 0000000..07a70e6 --- /dev/null +++ b/chat/scheduler.mjs @@ -0,0 +1,216 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WORKFLOWS_DIR = join(__dirname, '..', 'workflows'); +const SCHEDULES_FILE = join(WORKFLOWS_DIR, 'schedules.json'); + +// Active timers: scheduleId → timeout handle +const activeTimers = new Map(); + +// Reference to the onTrigger callback (set by startScheduler) +let _onTrigger = null; + +// ---- Helpers ---- + +function atomicWriteJSON(filepath, data) { + const tmp = filepath + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, filepath); +} + +// Parse "minute hour * * *" style cron (daily-only subset, also supports day-of-week) +function parseCron(cron) { + const parts = cron.split(' '); + return { + minute: parseInt(parts[0], 10), + hour: parseInt(parts[1], 10), + dayOfMonth: parts[2], // '*' or number + month: parts[3], // '*' or number + dayOfWeek: parts[4], // '*' or number (0=Sun) + }; +} + +// Milliseconds until next occurrence of a cron schedule +function msUntilNextCron(cron) { + const { minute, hour, dayOfWeek } = parseCron(cron); + const now = new Date(); + const next = new Date(); + next.setHours(hour, minute, 0, 0); + + if (dayOfWeek !== '*') { + const targetDay = parseInt(dayOfWeek, 10); + const currentDay = now.getDay(); + let daysAhead = targetDay - currentDay; + if (daysAhead < 0) daysAhead += 7; + if (daysAhead === 0 && next <= now) daysAhead = 7; + next.setDate(next.getDate() + daysAhead); + } else { + if (next <= now) next.setDate(next.getDate() + 1); + } + + return next.getTime() - now.getTime(); +} + +function loadSchedules() { + try { + return JSON.parse(readFileSync(SCHEDULES_FILE, 'utf8')); + } catch (err) { + console.error('[Scheduler] Failed to load schedules.json:', err.message); + return { schedules: [] }; + } +} + +export function updateLastRun(scheduleId) { + try { + const data = loadSchedules(); + const s = data.schedules.find(s => s.id === scheduleId); + if (s) { + s.lastRun = new Date().toISOString(); + atomicWriteJSON(SCHEDULES_FILE, data); + } + } catch (err) { + console.error('[Scheduler] Failed to update lastRun:', err.message); + } +} + +function ensureWorkflowsDir() { + if (!existsSync(WORKFLOWS_DIR)) mkdirSync(WORKFLOWS_DIR, { recursive: true }); +} + +// ---- Schedule a single schedule entry ---- + +function scheduleOne(schedule, onTrigger) { + // Clear any existing timer for this schedule + clearScheduleTimer(schedule.id); + + if (!schedule.enabled) return; + + // runAt-based schedule (one-time delayed execution) + if (schedule.runAt && !schedule.cron) { + const runAtTime = new Date(schedule.runAt).getTime(); + const delay = runAtTime - Date.now(); + + if (delay < 0) { + // runAt is in the past — trigger only if never run before + if (!schedule.lastRun) { + console.log(`[Scheduler] runAt "${schedule.id}" is past due, triggering now`); + triggerAndUpdate(schedule, onTrigger); + } else { + console.log(`[Scheduler] runAt "${schedule.id}" already ran, skipping`); + } + return; + } + + const delayMin = Math.round(delay / 1000 / 60); + console.log(`[Scheduler] runAt "${schedule.id}" scheduled in ${delayMin} min (${schedule.runAt})`); + const timer = setTimeout(() => { + activeTimers.delete(schedule.id); + triggerAndUpdate(schedule, onTrigger); + }, delay); + activeTimers.set(schedule.id, timer); + return; + } + + // interval-based schedule (recurring with fixed interval) + if (schedule.intervalMs && !schedule.cron) { + const intervalMin = Math.round(schedule.intervalMs / 1000 / 60); + console.log(`[Scheduler] "${schedule.id}" interval every ${intervalMin} min`); + const timer = setTimeout(function tick() { + console.log(`[Scheduler] Triggering "${schedule.id}" (interval)`); + triggerAndUpdate(schedule, onTrigger); + // Re-schedule from fresh disk state (might have been disabled/updated) + const freshData = loadSchedules(); + const freshSchedule = freshData.schedules.find(s => s.id === schedule.id); + if (freshSchedule && freshSchedule.enabled && freshSchedule.intervalMs) { + const nextTimer = setTimeout(tick, freshSchedule.intervalMs); + activeTimers.set(schedule.id, nextTimer); + } + }, schedule.intervalMs); + activeTimers.set(schedule.id, timer); + return; + } + + // cron-based schedule + if (!schedule.cron) return; + + const { hour, minute } = parseCron(schedule.cron); + + // Missed-run detection + if (schedule.lastRun !== null) { + const lastRun = new Date(schedule.lastRun); + const expectedToday = new Date(); + expectedToday.setHours(hour, minute, 0, 0); + const now = new Date(); + if (lastRun < expectedToday && expectedToday <= now) { + console.log(`[Scheduler] Missed run detected for "${schedule.id}", triggering now`); + triggerAndUpdate(schedule, onTrigger); + } + } + + const delay = msUntilNextCron(schedule.cron); + const delayMin = Math.round(delay / 1000 / 60); + console.log(`[Scheduler] "${schedule.id}" scheduled in ${delayMin} min (${schedule.cron})`); + + const timer = setTimeout(() => { + activeTimers.delete(schedule.id); + console.log(`[Scheduler] Triggering "${schedule.id}"`); + triggerAndUpdate(schedule, onTrigger); + // Re-schedule for next occurrence (reload from disk to get fresh data) + const freshData = loadSchedules(); + const freshSchedule = freshData.schedules.find(s => s.id === schedule.id); + if (freshSchedule) { + scheduleOne(freshSchedule, onTrigger); + } + }, delay); + activeTimers.set(schedule.id, timer); +} + +async function triggerAndUpdate(schedule, onTrigger) { + updateLastRun(schedule.id); + try { + const result = await onTrigger(schedule); + // If the schedule was removed by post-run logic (maxRuns reached), clear its timer + if (result?.meta?._scheduleRemoved) { + clearScheduleTimer(schedule.id); + console.log(`[Scheduler] Schedule "${schedule.id}" removed after maxRuns reached`); + } + } catch (err) { + console.error(`[Scheduler] onTrigger error for "${schedule.id}":`, err); + } +} + +// ---- Public API ---- + +function clearScheduleTimer(scheduleId) { + const existing = activeTimers.get(scheduleId); + if (existing) { + clearTimeout(existing); + activeTimers.delete(scheduleId); + } +} + +/** + * Reload a single schedule (e.g. after PATCH update). + * Clears the old timer and re-schedules based on current disk state. + */ +export function reloadSchedule(scheduleId) { + if (!_onTrigger) return; + clearScheduleTimer(scheduleId); + const data = loadSchedules(); + const schedule = data.schedules.find(s => s.id === scheduleId); + if (schedule) { + scheduleOne(schedule, _onTrigger); + } +} + +export function startScheduler(onTrigger) { + ensureWorkflowsDir(); + _onTrigger = onTrigger; + + const data = loadSchedules(); + for (const schedule of data.schedules) { + scheduleOne(schedule, onTrigger); + } +} diff --git a/chat/task-manager.mjs b/chat/task-manager.mjs new file mode 100644 index 0000000..040d68e --- /dev/null +++ b/chat/task-manager.mjs @@ -0,0 +1,175 @@ +import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs'; +import { randomBytes } from 'crypto'; +import { TASKS_FILE } from '../lib/config.mjs'; + +// In-memory task store +let tasks = []; + +// Callback invoked when a blocked task becomes pending and has an assigned session. +// Injected by initTaskManager() — router provides the actual sendMessage function. +let _onTaskReady = null; + +// ---- Helpers ---- + +function atomicWriteJSON(filepath, data) { + const tmp = filepath + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, filepath); +} + +function loadTasks() { + try { + if (existsSync(TASKS_FILE)) { + const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf8')); + tasks = Array.isArray(raw) ? raw : []; + } + } catch (err) { + console.error('[TaskManager] Failed to load tasks.json:', err.message); + tasks = []; + } +} + +function saveTasks() { + try { + atomicWriteJSON(TASKS_FILE, tasks); + } catch (err) { + console.error('[TaskManager] Failed to save tasks.json:', err.message); + } +} + +// ---- Dependency resolution ---- + +/** + * Called whenever a task transitions to 'completed'. + * Removes completedTaskId from blocked_by of all blocked tasks. + * If a task's blocked_by becomes empty, it moves to 'pending' and + * _onTaskReady is called if it has an assigned session. + */ +function resolveDependencies(completedTaskId) { + let changed = false; + for (const t of tasks) { + if (t.status !== 'blocked') continue; + if (!t.blocked_by.includes(completedTaskId)) continue; + + t.blocked_by = t.blocked_by.filter(id => id !== completedTaskId); + + if (t.blocked_by.length === 0) { + t.status = 'pending'; + changed = true; + console.log(`[TaskManager] Task "${t.id}" unblocked (all deps resolved)`); + + if (t.assigned_session_id && _onTaskReady) { + // Fire-and-forget — don't let callback errors stop the loop + Promise.resolve(_onTaskReady(t)).catch(err => { + console.error(`[TaskManager] onTaskReady error for task ${t.id}:`, err.message); + }); + } + } + } + if (changed) saveTasks(); +} + +// ---- Public API ---- + +/** + * Initialize the task manager. + * @param {Function} onTaskReady - Called with (task) when a blocked task becomes pending. + * Signature: async (task) => void. Should send a message to task.assigned_session_id. + */ +export function initTaskManager(onTaskReady) { + _onTaskReady = onTaskReady; + loadTasks(); + console.log(`[TaskManager] Loaded ${tasks.length} task(s) from disk`); +} + +/** + * Create a new task. + * Status is automatically set to 'blocked' if blocked_by is non-empty, else 'pending'. + */ +export function createTask({ subject, description = '', assigned_session_id = null, blocked_by = [], report_to = null, repo_path = null, worktree_path = null, branch = null }) { + const id = randomBytes(8).toString('hex'); + const task = { + id, + subject, + description, + status: blocked_by.length > 0 ? 'blocked' : 'pending', + assigned_session_id, + blocked_by: [...blocked_by], + report_to, + repo_path, + worktree_path, + branch, + created_at: new Date().toISOString(), + completed_at: null, + }; + tasks.push(task); + saveTasks(); + console.log(`[TaskManager] Created task "${id}" (${task.status}): ${subject}`); + return task; +} + +/** + * Get a single task by ID. Returns null if not found. + */ +export function getTask(id) { + return tasks.find(t => t.id === id) || null; +} + +/** + * List tasks, optionally filtered. + * @param {Object} filters - { status?, assigned_session_id? } + */ +export function listTasks({ status, assigned_session_id } = {}) { + let result = tasks; + if (status) result = result.filter(t => t.status === status); + if (assigned_session_id) result = result.filter(t => t.assigned_session_id === assigned_session_id); + return result; +} + +/** + * Update a task's mutable fields. + * Handles status transitions: sets completed_at, triggers dependency resolution. + * @param {string} id - Task ID + * @param {Object} updates - Partial task fields to update + * @returns Updated task, or null if not found + */ +export function updateTask(id, updates) { + const task = tasks.find(t => t.id === id); + if (!task) return null; + + const prevStatus = task.status; + + if (updates.subject !== undefined) task.subject = updates.subject; + if (updates.description !== undefined) task.description = updates.description; + if (updates.assigned_session_id !== undefined) task.assigned_session_id = updates.assigned_session_id; + if (updates.blocked_by !== undefined) task.blocked_by = updates.blocked_by; + if (updates.status !== undefined) task.status = updates.status; + if (updates.repo_path !== undefined) task.repo_path = updates.repo_path; + if (updates.worktree_path !== undefined) task.worktree_path = updates.worktree_path; + if (updates.branch !== undefined) task.branch = updates.branch; + + // Auto-set completed_at on first completion + if (task.status === 'completed' && !task.completed_at) { + task.completed_at = new Date().toISOString(); + } + + saveTasks(); + + // Trigger dependency resolution if this task just completed + if (prevStatus !== 'completed' && task.status === 'completed') { + resolveDependencies(task.id); + } + + return task; +} + +/** + * Delete a task by ID. Returns true if found and deleted. + */ +export function deleteTask(id) { + const idx = tasks.findIndex(t => t.id === id); + if (idx === -1) return false; + tasks.splice(idx, 1); + saveTasks(); + return true; +} diff --git a/chat/workflow-engine.mjs b/chat/workflow-engine.mjs new file mode 100644 index 0000000..3f6c1ef --- /dev/null +++ b/chat/workflow-engine.mjs @@ -0,0 +1,228 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, renameSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { randomBytes } from 'crypto'; +import { homedir } from 'os'; +import { createSession, setSessionArchived, sendMessage } from './session-manager.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WORKFLOWS_DIR = join(__dirname, '..', 'workflows'); +const RUNS_DIR = join(homedir(), '.config', 'claude-web', 'workflow-runs'); +const SCHEDULES_FILE = join(WORKFLOWS_DIR, 'schedules.json'); + +// ---- Atomic write helper ---- + +function atomicWriteJSON(filepath, data) { + const tmp = filepath + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, filepath); +} + +// ---- Placeholder resolution ---- + +function resolvePlaceholders(prompt, results) { + return prompt.replace(/\{\{(\w+)\.results\}\}/g, (_, stepId) => { + if (!results[stepId]) return '[results not available]'; + return Object.entries(results[stepId]) + .map(([id, output]) => `### ${id}\n${output}`) + .join('\n\n'); + }); +} + +// ---- Single task runner ---- + +async function runSessionMessageTask(task, runDir) { + console.log(`[Workflow] Sending message to session "${task.sessionId}"`); + await sendMessage(task.sessionId, task.text); + const output = `Message sent to session ${task.sessionId}`; + writeFileSync(join(runDir, `${task.id}.txt`), output, 'utf8'); + console.log(`[Workflow] sessionMessage task "${task.id}" completed`); + return output; +} + +async function runTask(task, runDir, sessionIds) { + if (task.type === 'sessionMessage') { + return runSessionMessageTask(task, runDir); + } + // Default: create session and send message + console.log(`[Workflow] Running task "${task.id}" in ${task.workspace} (model: ${task.model})`); + const session = await createSession(task.workspace, 'claude', task.id, { + model: task.model || 'sonnet', + }); + const sessionId = session.id; + sessionIds.push(sessionId); + await sendMessage(sessionId, task.prompt); + const output = `Session ${sessionId} created and message sent`; + writeFileSync(join(runDir, `${task.id}.txt`), output, 'utf8'); + console.log(`[Workflow] Task "${task.id}" completed, session=${sessionId.slice(0, 8)}`); + return output; +} + +// ---- Schedule post-run logic ---- + +function loadSchedules() { + try { + return JSON.parse(readFileSync(SCHEDULES_FILE, 'utf8')); + } catch (err) { + console.error('[Workflow] Failed to parse schedules.json:', err.message); + return { schedules: [] }; + } +} + +function handleDisposable(schedule, sessionIds, runDir) { + if (!schedule?.disposable) return; + console.log(`[Workflow] Disposable: archiving ${sessionIds.length} session(s) for schedule "${schedule.id}"`); + for (const sid of sessionIds) { + setSessionArchived(sid, true); + } + // Mark run meta as archived + try { + const metaPath = join(runDir, 'meta.json'); + const meta = JSON.parse(readFileSync(metaPath, 'utf8')); + meta.archived = true; + writeFileSync(metaPath, JSON.stringify(meta, null, 2)); + } catch (err) { + console.error(`[Workflow] Failed to mark run as archived:`, err.message); + } +} + +function handleRunCount(schedule) { + if (!schedule) return; + const data = loadSchedules(); + const s = data.schedules.find(s => s.id === schedule.id); + if (!s) return; + + s.runCount = (s.runCount || 0) + 1; + console.log(`[Workflow] Schedule "${schedule.id}" runCount=${s.runCount}/${s.maxRuns ?? '∞'}`); + + if (s.maxRuns !== null && s.maxRuns !== undefined && s.runCount >= s.maxRuns) { + console.log(`[Workflow] Schedule "${schedule.id}" reached maxRuns=${s.maxRuns}, removing`); + data.schedules = data.schedules.filter(x => x.id !== schedule.id); + atomicWriteJSON(SCHEDULES_FILE, data); + // Notify scheduler to clear timer (via returned flag) + return 'removed'; + } + + atomicWriteJSON(SCHEDULES_FILE, data); + return 'updated'; +} + +// ---- Main export ---- + +export async function executeWorkflow(workflowName, options = {}) { + const { schedule, inlineWorkflow } = options; + + let workflow; + if (inlineWorkflow) { + workflow = inlineWorkflow; + } else { + const workflowPath = join(WORKFLOWS_DIR, `${workflowName}.json`); + if (!existsSync(workflowPath)) { + throw new Error(`Workflow definition not found: ${workflowPath}`); + } + workflow = JSON.parse(readFileSync(workflowPath, 'utf8')); + } + + const runId = options.runId || randomBytes(8).toString('hex'); + const runDir = join(RUNS_DIR, runId); + mkdirSync(runDir, { recursive: true }); + + console.log(`[Workflow] Starting "${workflowName || workflow.name}" run=${runId}`); + + const meta = { + runId, + workflow: workflowName, + startedAt: new Date().toISOString(), + status: 'running', + steps: {}, + }; + if (schedule) meta.scheduleId = schedule.id; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta, null, 2)); + + const results = {}; // stepId → { taskId → output } + const sessionIds = []; // track all sessions created during this run + + try { + for (const step of workflow.steps) { + console.log(`[Workflow] Executing step "${step.id}" (type: ${step.type})`); + + const resolvedTasks = step.tasks.map(task => ({ + ...task, + ...(task.prompt ? { prompt: resolvePlaceholders(task.prompt, results) } : {}), + })); + + if (step.type === 'parallel') { + const settled = await Promise.allSettled( + resolvedTasks.map(async task => { + const output = await runTask(task, runDir, sessionIds); + return [task.id, output]; + }) + ); + for (const [i, r] of settled.entries()) { + if (r.status === 'rejected') { + console.error(`[Workflow] Task "${resolvedTasks[i].id}" failed in parallel step:`, r.reason?.message); + } + } + results[step.id] = Object.fromEntries( + settled.map((r, i) => [ + resolvedTasks[i].id, + r.status === 'fulfilled' ? r.value[1] : `[FAILED: ${r.reason?.message ?? 'unknown error'}]`, + ]) + ); + } else { + results[step.id] = {}; + for (const task of resolvedTasks) { + const output = await runTask(task, runDir, sessionIds); + results[step.id][task.id] = output; + } + } + + meta.steps[step.id] = { status: 'completed', tasks: Object.keys(results[step.id]) }; + } + + meta.status = 'completed'; + meta.completedAt = new Date().toISOString(); + console.log(`[Workflow] "${workflowName}" run=${runId} completed successfully`); + } catch (err) { + meta.status = 'failed'; + meta.error = err.message; + meta.errorStack = err.stack; + meta.failedAt = new Date().toISOString(); + console.error(`[Workflow] "${workflowName}" run=${runId} failed:`, err.message); + } + + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta, null, 2)); + + // Post-run schedule lifecycle + if (schedule) { + handleDisposable(schedule, sessionIds, runDir); + const result = handleRunCount(schedule); + meta._scheduleRemoved = result === 'removed'; + } + + return { runId, runDir, meta }; +} + +// ---- Utility: list recent runs ---- + +export function listWorkflowRuns(limit = 10) { + if (!existsSync(RUNS_DIR)) return []; + try { + const dirs = readdirSync(RUNS_DIR) + .map(name => ({ name, mtime: statSync(join(RUNS_DIR, name)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime) + .slice(0, limit) + .map(({ name }) => { + try { + return JSON.parse(readFileSync(join(RUNS_DIR, name, 'meta.json'), 'utf8')); + } catch (err) { + console.error(`[Workflow] Failed to load workflow run "${name}":`, err.message); + return { runId: name, status: 'unknown' }; + } + }); + return dirs; + } catch (err) { + console.error('[Workflow] Failed to list workflow runs:', err.message); + return []; + } +} diff --git a/lib/config.mjs b/lib/config.mjs index c842119..cf0a2f7 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -146,6 +146,9 @@ export const AUTH_SESSIONS_FILE = join(configDir, 'auth-sessions.json'); export const INSTALL_HANDOFFS_FILE = join(configDir, 'install-handoffs.json'); export const CHAT_SESSIONS_FILE = join(configDir, 'chat-sessions.json'); export const CHAT_TRIGGERS_FILE = join(configDir, 'chat-triggers.json'); +export const TASKS_FILE = join(configDir, 'tasks.json'); +export const REPORTS_DIR = join(configDir, 'reports'); +export const REPORTS_META_FILE = join(configDir, 'reports.json'); export const CHAT_HISTORY_DIR = join(configDir, 'chat-history'); export const CHAT_RUNS_DIR = join(configDir, 'chat-runs'); export const CHAT_IMAGES_DIR = join(configDir, 'images'); diff --git a/mcp-server.mjs b/mcp-server.mjs new file mode 100644 index 0000000..d8afbbf --- /dev/null +++ b/mcp-server.mjs @@ -0,0 +1,813 @@ +#!/usr/bin/env node +/** + * RemoteLab MCP Server + * + * Exposes RemoteLab session management as MCP tools over stdio transport. + * Communicates with the chat-server via HTTP API on localhost. + * + * Usage: + * node mcp-server.mjs + * + * Environment variables: + * CHAT_PORT — chat-server port (default: 7690) + * + * The server reads the auth token from ~/.config/claude-web/auth.json automatically. + */ + +import { readFileSync, writeFileSync, renameSync } from 'fs'; +import { createInterface } from 'readline'; +import { execSync } from 'child_process'; +import { homedir } from 'os'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import http from 'http'; +import { TASK_TOOLS, executeTaskTool } from './mcp-task-tools.mjs'; + +const AUTH_FILE = join(homedir(), '.config', 'claude-web', 'auth.json'); +const CHAT_PORT = parseInt(process.env.CHAT_PORT, 10) || 7690; +const BASE_URL = `http://127.0.0.1:${CHAT_PORT}`; +const MY_SESSION_ID = process.env.REMOTELAB_SESSION_ID || null; +const WORKFLOWS_DIR = join(import.meta.dirname, 'workflows'); +const SCHEDULES_FILE = join(WORKFLOWS_DIR, 'schedules.json'); + +// ---- Auth token ---- + +let authToken; +try { + const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf8')); + authToken = auth.token; +} catch (err) { + process.stderr.write(`[mcp] Failed to read auth token from ${AUTH_FILE}: ${err.message}\n`); + process.exit(1); +} + +// ---- HTTP client ---- + +function apiRequest(method, path, body = null) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const options = { + method, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, data }); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +// ---- MCP stdio transport (newline-delimited JSON-RPC 2.0) ---- + +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + const msg = JSON.parse(trimmed); + handleMessage(msg).catch(err => { + process.stderr.write(`[mcp] handleMessage error: ${err.message}\n`); + }); + } catch (err) { + process.stderr.write(`[mcp] Failed to parse: ${err.message}\n`); + } +}); + +rl.on('close', () => { + process.exit(0); +}); + +function sendResponse(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} + +function sendResult(id, result) { + sendResponse({ jsonrpc: '2.0', id, result }); +} + +function sendError(id, code, message) { + sendResponse({ jsonrpc: '2.0', id, error: { code, message } }); +} + +/** + * Send an MCP logging notification (server → client). + * Used to report async session completion. + */ +function sendNotification(level, data) { + sendResponse({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level, logger: 'remotelab', data }, + }); +} + +// ---- Background session watchers ---- + +// sessionId → { eventCountBefore, sessionName } +const activeWatchers = new Map(); + +async function watchSession(sessionId, eventCountBefore, sessionName, reportToSessionId = null) { + if (activeWatchers.has(sessionId)) return; // already watching + activeWatchers.set(sessionId, { eventCountBefore, sessionName }); + + const maxWait = 30 * 60 * 1000; // 30 minutes + const pollInterval = 3000; + const startTime = Date.now(); + + try { + while (Date.now() - startTime < maxWait) { + await new Promise(r => setTimeout(r, pollInterval)); + if (!activeWatchers.has(sessionId)) return; // was cancelled + + const statusRes = await apiRequest('GET', `/api/sessions/${sessionId}`); + if (statusRes.status !== 200) { + process.stderr.write(`[mcp] Session ${sessionId.slice(0,8)} status check failed: ${statusRes.status}\n`); + continue; + } + if (statusRes.data.session?.status !== 'idle') continue; + + // Session finished — build compact notification + const label = sessionName || sessionId.slice(0, 8); + const firstMsg = await getOriginalFirstMessage(sessionId); + const lastMsg = await getLastAssistantMessage(sessionId); + + const parts = [`[Session "${label}" completed]`]; + if (firstMsg) parts.push(`[Task] ${firstMsg}`); + if (lastMsg) parts.push(`[Result] ${lastMsg}`); + + sendNotification('info', parts.join('\n')); + + if (reportToSessionId) { + const reportText = `[子任务完成汇报]\n${parts.join('\n')}`; + try { + await apiRequest('POST', `/api/sessions/${reportToSessionId}/messages`, { text: reportText }); + } catch (err) { + sendNotification('error', `[report_to failed] ${err.message}`); + } + } + + break; + } + } catch (err) { + sendNotification('error', `[Session watcher error] ${sessionId.slice(0, 8)}: ${err.message}`); + } finally { + activeWatchers.delete(sessionId); + } +} + +/** + * Trace back through compact chain to find the original first user message. + */ +async function getOriginalFirstMessage(sessionId) { + let currentId = sessionId; + const visited = new Set(); + + // Follow continuedFrom chain to the root session + while (currentId && !visited.has(currentId)) { + visited.add(currentId); + const sessRes = await apiRequest('GET', `/api/sessions/${currentId}`); + if (sessRes.status !== 200) break; + const prev = sessRes.data.session?.continuedFrom; + if (!prev) break; + currentId = prev; + } + + // Get the first user message from the root session + const histRes = await apiRequest('GET', `/api/sessions/${currentId}/history`); + if (histRes.status !== 200) return null; + const events = histRes.data.events || []; + const first = events.find(e => e.type === 'message' && e.role === 'user'); + return first?.content || null; +} + +/** + * Get the last assistant message from a session's history. + */ +async function getLastAssistantMessage(sessionId) { + const histRes = await apiRequest('GET', `/api/sessions/${sessionId}/history`); + if (histRes.status !== 200) return null; + const events = histRes.data.events || []; + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].type === 'message' && events[i].role === 'assistant') { + return events[i].content; + } + } + return null; +} + +// ---- MCP Tool definitions ---- + +const TOOLS = [ + { + name: 'list_folders', + description: 'List all project folders that have RemoteLab sessions, with session counts and session details for each folder.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'list_sessions', + description: 'List all sessions, optionally filtered by folder path.', + inputSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Filter by folder path (exact match). If omitted, returns all sessions.' }, + }, + required: [], + }, + }, + { + name: 'get_session', + description: 'Get details of a specific session including its id, folder, tool, name, status, and creation time.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID (hex string).' }, + }, + required: ['session_id'], + }, + }, + { + name: 'get_session_history', + description: 'Get the full message/event history of a session. Events include user messages, assistant messages, tool uses, tool results, file changes, etc.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID.' }, + }, + required: ['session_id'], + }, + }, + { + name: 'create_session', + description: 'Create a new session in a project folder with a specific CLI tool (e.g. "claude", "codex").', + inputSchema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Project folder path. Supports ~ for home directory.' }, + tool: { type: 'string', description: 'CLI tool to use (e.g. "claude", "codex").' }, + name: { type: 'string', description: 'Session name/label for easy identification.' }, + }, + required: ['folder', 'tool'], + }, + }, + { + name: 'delete_session', + description: 'Delete a session. If the session has a running process, it will be cancelled first.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID to delete.' }, + }, + required: ['session_id'], + }, + }, + { + name: 'send_message', + description: 'Send a message to an AI session (fire-and-forget). The message is dispatched to the CLI tool and this returns immediately. When the session finishes, a notification is automatically sent back with the results. Set wait=true to block until the response is ready instead. Optionally set report_to to automatically send results back to another session when done.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID to send the message to.' }, + text: { type: 'string', description: 'The message text to send.' }, + wait: { type: 'boolean', description: 'If true, block until session finishes and return results directly. Default: false (async with notification).' }, + tool: { type: 'string', description: 'Override the CLI tool for this message (e.g. "claude", "codex").' }, + thinking: { type: 'boolean', description: 'Enable extended thinking mode.' }, + model: { type: 'string', description: 'Override the model to use.' }, + report_to: { type: 'string', description: 'Session ID to report back to when this session completes. The result will be sent as a chat message to that session, waking it up if idle or interrupting if busy.' }, + }, + required: ['session_id', 'text'], + }, + }, + { + name: 'list_tools', + description: 'List available CLI tools that can be used when creating sessions (e.g. claude, codex).', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'set_label', + description: 'Set a custom label/status on a session (e.g. "planned", "pending-review", "done"). If session_id is omitted, sets the label on the current session (self). Set label to null or omit it to clear.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID to label. If omitted, labels the current session (self).' }, + label: { type: 'string', description: 'Label ID to set (e.g. "planned", "pending-review", "done"). Omit or set to null to clear the label.' }, + }, + required: [], + }, + }, + { + name: 'list_labels', + description: 'List all available session labels with their names and colors.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'restart_server', + description: 'Restart all RemoteLab services. Two modes: "immediate" (kills all sessions, restarts now — only allowed for RLOrchestrator and remotelab sessions) and "wait" (monitors all sessions, auto-restarts when all are idle — available to all sessions). Default mode is "wait".', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'The session ID that triggered the restart. If omitted, uses the current session (self).' }, + mode: { type: 'string', enum: ['immediate', 'wait'], description: 'Restart mode: "immediate" forces restart now (restricted), "wait" waits for all sessions to be idle (default).', default: 'wait' }, + }, + required: [], + }, + }, + { + name: 'submit_report', + description: 'Submit a report (HTML file) to the Report notification system. The HTML file will be validated (structure, content length, tag balance) before acceptance — if validation fails, detailed errors are returned so you can fix and retry. Only available to authorized workspaces (RLOrchestrator, DailyNews).', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Report title (e.g., "惠远早报 — 2026-03-18")' }, + file_path: { type: 'string', description: 'Absolute path to the HTML file to submit' }, + session_id: { type: 'string', description: 'Session ID of the submitting agent (for permission check and linking)' }, + source: { type: 'string', description: 'Source agent name (e.g., "DailyNews", "RLOrchestrator")' }, + }, + required: ['title', 'file_path', 'session_id', 'source'], + }, + }, + { + name: 'schedule_message', + description: 'Schedule a message to be sent to a session. Supports one-shot (delay_ms or run_at) and recurring (interval_ms) modes. For one-shot: provide delay_ms or run_at (not both). For recurring: provide interval_ms (fires repeatedly at this interval).', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Target session ID to send the message to.' }, + text: { type: 'string', description: 'The message text to send.' }, + delay_ms: { type: 'number', description: 'Delay in milliseconds before sending. Mutually exclusive with run_at.' }, + run_at: { type: 'string', description: 'ISO 8601 timestamp for when to send. Mutually exclusive with delay_ms.' }, + interval_ms: { type: 'number', description: 'Interval in milliseconds for recurring delivery. Mutually exclusive with delay_ms and run_at.' }, + }, + required: ['session_id', 'text'], + }, + }, + ...TASK_TOOLS, +]; + +// ---- Tool execution ---- + +async function executeTool(name, args) { + switch (name) { + case 'list_folders': { + const res = await apiRequest('GET', '/api/folders'); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'list_sessions': { + const path = args.folder ? `/api/sessions?folder=${encodeURIComponent(args.folder)}` : '/api/sessions'; + const res = await apiRequest('GET', path); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'get_session': { + const res = await apiRequest('GET', `/api/sessions/${args.session_id}`); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'get_session_history': { + const res = await apiRequest('GET', `/api/sessions/${args.session_id}/history`); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'create_session': { + const body = { folder: args.folder, tool: args.tool }; + if (args.name) body.name = args.name; + const res = await apiRequest('POST', '/api/sessions', body); + if (res.status !== 201) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'delete_session': { + const res = await apiRequest('DELETE', `/api/sessions/${args.session_id}`); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'send_message': { + const wait = args.wait === true; // default false (async) + + // Snapshot current event count before sending + let eventCountBefore = 0; + const histRes = await apiRequest('GET', `/api/sessions/${args.session_id}/history`); + if (histRes.status === 200 && histRes.data.events) { + eventCountBefore = histRes.data.events.length; + } + + // Get session name for notification label + let sessionName = ''; + const sessRes = await apiRequest('GET', `/api/sessions/${args.session_id}`); + if (sessRes.status === 200) { + sessionName = sessRes.data.session?.name || ''; + } + + // Context injection: warn about other active sessions in the same folder + let messageText = args.text; + try { + const folder = sessRes.status === 200 ? sessRes.data.session?.folder : null; + if (folder) { + const allRes = await apiRequest('GET', `/api/sessions?folder=${encodeURIComponent(folder)}`); + if (allRes.status === 200 && allRes.data.sessions) { + const otherActive = allRes.data.sessions.filter( + s => s.id !== args.session_id && s.status === 'running' + ); + if (otherActive.length > 0) { + const names = otherActive.map(s => s.name || s.id.slice(0, 8)); + messageText = `\u26a0\ufe0f 同仓库其他活跃 session: [${names.join(', ')}]\n请注意协调,避免修改冲突。\n---\n${messageText}`; + } + } + } + } catch (err) { + process.stderr.write(`[mcp] Context injection failed: ${err.message}\n`); + } + + // Send the message (include report_to so chat-server handles the callback server-side) + const body = { text: messageText }; + if (args.tool) body.tool = args.tool; + if (args.thinking) body.thinking = true; + if (args.model) body.model = args.model; + const effectiveReportTo = (!args.report_to || args.report_to === 'current') ? MY_SESSION_ID : args.report_to; + if (effectiveReportTo) body.report_to = effectiveReportTo; + + const res = await apiRequest('POST', `/api/sessions/${args.session_id}/messages`, body); + if (res.status !== 202) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + + if (!wait) { + // Async: start background watcher, return immediately + watchSession(args.session_id, eventCountBefore, sessionName, effectiveReportTo); + return { content: [{ type: 'text', text: `Message dispatched to session "${sessionName || args.session_id.slice(0, 8)}". You will receive a notification when it completes.` }] }; + } + + // Sync: poll until session goes idle (max 10 minutes) + const maxWait = 10 * 60 * 1000; + const pollInterval = 2000; + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + await new Promise(r => setTimeout(r, pollInterval)); + const statusRes = await apiRequest('GET', `/api/sessions/${args.session_id}`); + if (statusRes.status !== 200) continue; + if (statusRes.data.session?.status === 'idle') break; + } + + // Fetch new events + const finalHist = await apiRequest('GET', `/api/sessions/${args.session_id}/history`); + if (finalHist.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${finalHist.status}: ${JSON.stringify(finalHist.data)}` }] }; + + const newEvents = finalHist.data.events.slice(eventCountBefore); + const summary = formatEvents(newEvents); + return { content: [{ type: 'text', text: summary }] }; + } + + case 'list_tools': { + const res = await apiRequest('GET', '/api/tools'); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'set_label': { + const targetId = args.session_id || MY_SESSION_ID; + if (!targetId) return { isError: true, content: [{ type: 'text', text: 'No session_id provided and no current session ID available (REMOTELAB_SESSION_ID not set).' }] }; + const res = await apiRequest('PATCH', `/api/sessions/${targetId}/label`, { label: args.label || null }); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'list_labels': { + const res = await apiRequest('GET', '/api/session-labels'); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'restart_server': { + const triggerId = args.session_id || MY_SESSION_ID; + const mode = args.mode || 'wait'; + const XDG = `XDG_RUNTIME_DIR=/run/user/${process.getuid()}`; + const log = []; + + // Permission check: only RLOrchestrator and remotelab sessions can use "immediate" + if (mode === 'immediate') { + const ALLOWED_FOLDERS = ['RLOrchestrator', 'huiyuanclaw/remotelab']; + let allowed = false; + if (triggerId) { + try { + const sessRes = await apiRequest('GET', `/api/sessions/${triggerId}`); + if (sessRes.status === 200) { + const folder = sessRes.data.session?.folder || ''; + allowed = ALLOWED_FOLDERS.some(f => folder.includes(f)); + } + } catch {} + } + if (!allowed) { + return { isError: true, content: [{ type: 'text', text: 'Permission denied: only RLOrchestrator and remotelab sessions can use immediate restart. Use mode="wait" instead.' }] }; + } + } + + // "wait" mode: ask chat-server to monitor and restart when all idle + if (mode === 'wait') { + try { + const res = await apiRequest('POST', '/api/restart', { session_id: triggerId, mode: 'wait' }); + if (res.status === 200) { + if (res.data.immediate) { + log.push('✓ All sessions were already idle — server restarting immediately'); + } else if (res.data.alreadyPending) { + log.push('⚠ A restart is already pending (requested at ' + new Date(res.data.requestedAt).toISOString() + ')'); + } else { + log.push('✓ Pending restart registered — server will restart when all sessions are idle'); + log.push(' UI will show a "waiting for restart" banner with a manual restart button'); + } + } else { + log.push(`✗ Failed to register pending restart: ${JSON.stringify(res.data)}`); + } + } catch (e) { + log.push(`✗ Error: ${e.message}`); + } + return { content: [{ type: 'text', text: log.join('\n') }] }; + } + + // "immediate" mode: original behavior + // Step 1: Label the triggering session (while chat-server is still alive) + if (triggerId) { + try { + await apiRequest('PATCH', `/api/sessions/${triggerId}/label`, { label: 'asked-for-restart' }); + log.push(`✓ Labeled session ${triggerId.slice(0, 8)} as "asked-for-restart"`); + } catch (e) { + log.push(`⚠ Could not label session: ${e.message}`); + } + } + + // Step 2: daemon-reload + restart chat & proxy + try { + execSync(`${XDG} systemctl --user daemon-reload`, { timeout: 10000 }); + log.push('✓ daemon-reload complete'); + } catch (e) { + log.push(`⚠ daemon-reload failed: ${e.message}`); + } + + try { + execSync(`${XDG} systemctl --user restart remotelab-chat.service remotelab-proxy.service`, { timeout: 30000 }); + log.push('✓ Restarted remotelab-chat + remotelab-proxy'); + } catch (e) { + log.push(`⚠ Restart failed: ${e.message}`); + } + + // Step 3: Wait for chat-server to come back (poll up to 30s) + let serverUp = false; + for (let i = 0; i < 15; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const health = await apiRequest('GET', '/api/session-labels'); + if (health.status === 200) { serverUp = true; break; } + } catch {} + } + log.push(serverUp ? '✓ Chat-server is back online' : '✗ Chat-server did not recover within 30s'); + + // Step 4: Check tunnel, start if needed + try { + const tunnelStatus = execSync(`${XDG} systemctl --user is-active remotelab-tunnel.service`, { timeout: 5000 }).toString().trim(); + if (tunnelStatus === 'active') { + log.push('✓ Cloudflare tunnel is active'); + } else { + execSync(`${XDG} systemctl --user start remotelab-tunnel.service`, { timeout: 10000 }); + log.push('✓ Cloudflare tunnel was down, restarted'); + } + } catch { + try { + execSync(`${XDG} systemctl --user start remotelab-tunnel.service`, { timeout: 10000 }); + log.push('✓ Cloudflare tunnel was down, restarted'); + } catch (e2) { + log.push(`⚠ Cloudflare tunnel failed to start: ${e2.message}`); + } + } + + // Step 5: Final status + try { + const status = execSync(`${XDG} systemctl --user is-active remotelab-chat.service remotelab-proxy.service remotelab-tunnel.service`, { timeout: 5000 }).toString().trim(); + const lines = status.split('\n'); + log.push(`\nService status:\n chat: ${lines[0] || '?'}\n proxy: ${lines[1] || '?'}\n tunnel: ${lines[2] || '?'}`); + } catch {} + + return { content: [{ type: 'text', text: log.join('\n') }] }; + } + + case 'submit_report': { + const res = await apiRequest('POST', '/api/internal/report', { + title: args.title, + file_path: args.file_path, + session_id: args.session_id || MY_SESSION_ID, + source: args.source, + }); + if (res.status === 201) { + return { content: [{ type: 'text', text: `Report submitted successfully.\nReport ID: ${res.data.reportId}\nTitle: ${args.title}` }] }; + } + return { isError: true, content: [{ type: 'text', text: `Report submission failed (${res.status}): ${res.data?.error || JSON.stringify(res.data)}` }] }; + } + + case 'schedule_message': { + const modes = [args.delay_ms, args.run_at, args.interval_ms].filter(Boolean).length; + if (modes === 0) { + return { isError: true, content: [{ type: 'text', text: 'Provide one of: delay_ms, run_at, or interval_ms.' }] }; + } + if (modes > 1) { + return { isError: true, content: [{ type: 'text', text: 'Provide only one of: delay_ms, run_at, or interval_ms.' }] }; + } + + let newSchedule; + const scheduleId = `msg-${Date.now()}-${randomBytes(2).toString('hex')}`; + const inlineWorkflow = { + name: 'schedule_message', + steps: [{ + id: 'send', + type: 'sequential', + tasks: [{ + id: 'msg', + type: 'sessionMessage', + sessionId: args.session_id, + text: args.text, + }], + }], + }; + + if (args.interval_ms) { + // Recurring interval schedule + newSchedule = { + id: scheduleId, + cron: null, + runAt: null, + intervalMs: args.interval_ms, + workflow: null, + inlineWorkflow, + enabled: true, + disposable: false, + maxRuns: null, + runCount: 0, + lastRun: null, + }; + } else { + // One-shot schedule + const runAt = args.run_at + ? new Date(args.run_at).toISOString() + : new Date(Date.now() + args.delay_ms).toISOString(); + newSchedule = { + id: scheduleId, + cron: null, + runAt, + workflow: null, + inlineWorkflow, + enabled: true, + disposable: true, + maxRuns: 1, + runCount: 0, + lastRun: null, + }; + } + + // Append to schedules.json + let data; + try { + data = JSON.parse(readFileSync(SCHEDULES_FILE, 'utf8')); + } catch (err) { + process.stderr.write(`[mcp] Failed to parse schedules.json: ${err.message}\n`); + data = { schedules: [] }; + } + data.schedules.push(newSchedule); + const tmp = SCHEDULES_FILE + '.tmp.' + process.pid; + writeFileSync(tmp, JSON.stringify(data, null, 2)); + renameSync(tmp, SCHEDULES_FILE); + + process.stderr.write(`[mcp] schedule_message registered: id=${scheduleId} ${args.interval_ms ? `intervalMs=${args.interval_ms}` : `runAt=${newSchedule.runAt}`} target=${args.session_id.slice(0,8)}\n`); + + // Tell the scheduler to pick it up + try { + await apiRequest('POST', `/api/schedules/${scheduleId}/reload`); + } catch { + // Scheduler will pick it up on next restart if reload fails + } + + const result = args.interval_ms + ? { scheduleId, intervalMs: args.interval_ms, recurring: true } + : { scheduleId, runAt: newSchedule.runAt }; + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + + case 'create_task': + case 'get_task': + case 'list_tasks': + case 'update_task': + case 'launch_team': + return await executeTaskTool(name, args, apiRequest); + + default: + return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + } +} + +/** + * Format events into a readable summary for the MCP client. + */ +function formatEvents(events) { + if (!events || events.length === 0) return '(no new events)'; + + const parts = []; + for (const evt of events) { + switch (evt.type) { + case 'message': + parts.push(`[${evt.role}] ${evt.content}`); + break; + case 'toolUse': + parts.push(`[tool_use: ${evt.toolName}] ${(evt.content || '').slice(0, 500)}`); + break; + case 'toolResult': + parts.push(`[tool_result: ${evt.toolName}] ${(evt.content || '').slice(0, 1000)}`); + break; + case 'fileChange': + parts.push(`[file_change: ${evt.filePath}] ${evt.changeType || ''}`); + break; + case 'status': + parts.push(`[status] ${evt.content}`); + break; + case 'usage': + parts.push(`[usage] input=${evt.inputTokens || 0} output=${evt.outputTokens || 0} cache_read=${evt.cacheReadTokens || 0}`); + break; + default: + parts.push(`[${evt.type}] ${evt.content || JSON.stringify(evt).slice(0, 200)}`); + } + } + return parts.join('\n'); +} + +// ---- MCP message handler ---- + +async function handleMessage(msg) { + // Notifications (no id) — just acknowledge + if (msg.id === undefined || msg.id === null) { + return; + } + + switch (msg.method) { + case 'initialize': { + sendResult(msg.id, { + protocolVersion: '2024-11-05', + capabilities: { + tools: {}, + logging: {}, + }, + serverInfo: { + name: 'remotelab', + version: '1.0.0', + }, + }); + break; + } + + case 'tools/list': { + sendResult(msg.id, { tools: TOOLS }); + break; + } + + case 'tools/call': { + const { name, arguments: args } = msg.params; + try { + const result = await executeTool(name, args || {}); + sendResult(msg.id, result); + } catch (err) { + process.stderr.write(`[mcp] tool error: ${err.message}\n`); + sendResult(msg.id, { + isError: true, + content: [{ type: 'text', text: `Tool execution failed: ${err.message}` }], + }); + } + break; + } + + case 'ping': { + sendResult(msg.id, {}); + break; + } + + default: { + sendError(msg.id, -32601, `Method not found: ${msg.method}`); + } + } +} + +process.stderr.write(`[mcp] RemoteLab MCP server started (chat-server: ${BASE_URL})\n`); diff --git a/mcp-task-server.mjs b/mcp-task-server.mjs new file mode 100644 index 0000000..9e4670d --- /dev/null +++ b/mcp-task-server.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * RemoteLab MCP Task Server (task-only) + * + * Exposes ONLY task management tools — no session tools. + * Intended for sub-workspace agents (ResearchCenter, skiplec, etc.) + * that need to receive and report tasks without access to session management. + * + * Session MCP (mcp-server.mjs) is reserved for RLOrchestrator only. + * + * Usage: + * node mcp-task-server.mjs + * + * Environment variables: + * CHAT_PORT — chat-server port (default: 7690) + */ + +import { readFileSync } from 'fs'; +import { createInterface } from 'readline'; +import { homedir } from 'os'; +import { join } from 'path'; +import http from 'http'; +import { TASK_TOOLS, executeTaskTool } from './mcp-task-tools.mjs'; + +const AUTH_FILE = join(homedir(), '.config', 'claude-web', 'auth.json'); +const CHAT_PORT = parseInt(process.env.CHAT_PORT, 10) || 7690; +const BASE_URL = `http://127.0.0.1:${CHAT_PORT}`; + +// ---- Auth token ---- + +let authToken; +try { + const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf8')); + authToken = auth.token; +} catch (err) { + process.stderr.write(`[mcp-tasks] Failed to read auth token from ${AUTH_FILE}: ${err.message}\n`); + process.exit(1); +} + +// ---- HTTP client ---- + +function apiRequest(method, path, body = null) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const options = { + method, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, data }); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +// ---- MCP stdio transport (newline-delimited JSON-RPC 2.0) ---- + +const rl = createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + const msg = JSON.parse(trimmed); + handleMessage(msg).catch(err => { + process.stderr.write(`[mcp-tasks] handleMessage error: ${err.message}\n`); + }); + } catch (err) { + process.stderr.write(`[mcp-tasks] Failed to parse: ${err.message}\n`); + } +}); + +rl.on('close', () => { + process.exit(0); +}); + +function sendResponse(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} + +function sendResult(id, result) { + sendResponse({ jsonrpc: '2.0', id, result }); +} + +function sendError(id, code, message) { + sendResponse({ jsonrpc: '2.0', id, error: { code, message } }); +} + +// ---- Task tools only (no launch_team, no session tools) ---- + +const EXPOSED_TOOLS = TASK_TOOLS.filter(t => + ['create_task', 'get_task', 'list_tasks', 'update_task'].includes(t.name) +); + +// ---- Message handler ---- + +async function handleMessage(msg) { + const { id, method, params } = msg; + + if (method === 'initialize') { + sendResult(id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'remotelab-tasks', version: '1.0.0' }, + }); + return; + } + + if (method === 'notifications/initialized') return; + if (method === 'ping') { sendResult(id, {}); return; } + + if (method === 'tools/list') { + sendResult(id, { tools: EXPOSED_TOOLS }); + return; + } + + if (method === 'tools/call') { + const { name, arguments: args } = params; + if (!EXPOSED_TOOLS.find(t => t.name === name)) { + sendError(id, -32601, `Unknown tool: ${name}`); + return; + } + try { + const result = await executeTaskTool(name, args || {}, apiRequest); + sendResult(id, result); + } catch (err) { + sendResult(id, { + isError: true, + content: [{ type: 'text', text: `Tool error: ${err.message}` }], + }); + } + return; + } + + sendError(id, -32601, `Method not found: ${method}`); +} diff --git a/mcp-task-tools.mjs b/mcp-task-tools.mjs new file mode 100644 index 0000000..a3d616f --- /dev/null +++ b/mcp-task-tools.mjs @@ -0,0 +1,274 @@ +/** + * RemoteLab MCP Task Tools + * + * Provides task management MCP tools that communicate with the chat-server + * via HTTP API. Designed to be imported and registered by mcp-server.mjs. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const TEMPLATES_FILE = join(import.meta.dirname, 'team-templates.json'); + +// ---- MCP Tool Definitions ---- + +export const TASK_TOOLS = [ + { + name: 'create_task', + description: 'Create a task with subject, description, assigned session, and dependencies. Status is auto-set to "blocked" if blocked_by is non-empty, else "pending".', + inputSchema: { + type: 'object', + properties: { + subject: { type: 'string', description: 'Task subject / title.' }, + description: { type: 'string', description: 'Detailed description of the task.' }, + assigned_session_id: { type: 'string', description: 'Session ID to assign this task to.' }, + blocked_by: { + type: 'array', + items: { type: 'string' }, + description: 'Array of task IDs that must complete before this task can start.', + }, + report_to: { + type: 'string', + description: 'Session ID to report back to when this task\'s assigned session completes.', + }, + }, + required: ['subject'], + }, + }, + { + name: 'get_task', + description: 'Get a task by its ID, including status, dependencies, and assignment.', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'The task ID.' }, + }, + required: ['task_id'], + }, + }, + { + name: 'list_tasks', + description: 'List all tasks, optionally filtered by status and/or assigned session.', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by status: pending, blocked, in_progress, completed.' }, + assigned_session_id: { type: 'string', description: 'Filter by assigned session ID.' }, + }, + required: [], + }, + }, + { + name: 'update_task', + description: 'Update a task\'s fields. Setting status to "completed" automatically triggers dependency resolution and auto-dispatch of unblocked tasks.', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'The task ID to update.' }, + subject: { type: 'string', description: 'New subject.' }, + description: { type: 'string', description: 'New description.' }, + status: { type: 'string', description: 'New status: pending, blocked, in_progress, completed.' }, + assigned_session_id: { type: 'string', description: 'New assigned session ID.' }, + blocked_by: { + type: 'array', + items: { type: 'string' }, + description: 'New list of blocking task IDs.', + }, + }, + required: ['task_id'], + }, + }, + { + name: 'launch_team', + description: 'Launch a team from a predefined template. Creates sessions for each role, creates tasks with dependencies, and sends startup messages to unblocked tasks. Available templates: "software-dev" (leader + backend + frontend + tester), "research" (researcher + analyzer + writer).', + inputSchema: { + type: 'object', + properties: { + template_name: { type: 'string', description: 'Template name (e.g. "software-dev", "research").' }, + goal: { type: 'string', description: 'The project goal — injected into prompt templates via {{goal}}.' }, + goal_folder: { type: 'string', description: 'Project folder path — injected via {{goal_folder}} and used as session folder.' }, + team_name: { type: 'string', description: 'Team name prefix for session naming (e.g. "auth-refactor").' }, + }, + required: ['template_name', 'goal', 'goal_folder'], + }, + }, +]; + +// ---- Tool Execution ---- + +/** + * Execute a task-related MCP tool. + * @param {string} name - Tool name + * @param {Object} args - Tool arguments + * @param {Function} apiRequest - HTTP client: (method, path, body?) => { status, data } + * @returns MCP tool result + */ +export async function executeTaskTool(name, args, apiRequest) { + switch (name) { + case 'create_task': { + const body = { subject: args.subject }; + if (args.description) body.description = args.description; + if (args.assigned_session_id) body.assigned_session_id = args.assigned_session_id; + if (args.blocked_by) body.blocked_by = args.blocked_by; + if (args.report_to) body.report_to = args.report_to; + const res = await apiRequest('POST', '/api/tasks', body); + if (res.status !== 201) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'get_task': { + const res = await apiRequest('GET', `/api/tasks/${args.task_id}`); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'list_tasks': { + const params = new URLSearchParams(); + if (args.status) params.set('status', args.status); + if (args.assigned_session_id) params.set('assigned_session_id', args.assigned_session_id); + const qs = params.toString(); + const path = qs ? `/api/tasks?${qs}` : '/api/tasks'; + const res = await apiRequest('GET', path); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'update_task': { + const body = {}; + if (args.subject !== undefined) body.subject = args.subject; + if (args.description !== undefined) body.description = args.description; + if (args.status !== undefined) body.status = args.status; + if (args.assigned_session_id !== undefined) body.assigned_session_id = args.assigned_session_id; + if (args.blocked_by !== undefined) body.blocked_by = args.blocked_by; + const res = await apiRequest('PATCH', `/api/tasks/${args.task_id}`, body); + if (res.status !== 200) return { isError: true, content: [{ type: 'text', text: `Error ${res.status}: ${JSON.stringify(res.data)}` }] }; + return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] }; + } + + case 'launch_team': { + return await launchTeam(args, apiRequest); + } + + default: + return { isError: true, content: [{ type: 'text', text: `Unknown task tool: ${name}` }] }; + } +} + +// ---- launch_team implementation ---- + +async function launchTeam(args, apiRequest) { + const { template_name, goal, goal_folder, team_name } = args; + + // 1. Load template + let templates; + try { + templates = JSON.parse(readFileSync(TEMPLATES_FILE, 'utf8')); + } catch (err) { + return { isError: true, content: [{ type: 'text', text: `Failed to read team-templates.json: ${err.message}` }] }; + } + + const template = templates[template_name]; + if (!template) { + const available = Object.keys(templates).join(', '); + return { isError: true, content: [{ type: 'text', text: `Unknown template "${template_name}". Available: ${available}` }] }; + } + + const prefix = team_name || template_name; + const log = []; + + // 2. Create sessions for each role + const roleToSessionId = {}; + for (const role of template.roles) { + const folder = role.folder.replace(/\{\{goal_folder\}\}/g, goal_folder); + const sessionName = `${prefix}-${role.name}`; + const body = { folder, tool: role.tool, name: sessionName }; + const res = await apiRequest('POST', '/api/sessions', body); + if (res.status !== 201) { + log.push(`Failed to create session for role "${role.name}": ${res.status} ${JSON.stringify(res.data)}`); + continue; + } + roleToSessionId[role.name] = res.data.session.id; + log.push(`Created session "${sessionName}" (${res.data.session.id.slice(0, 8)}) for role "${role.name}"`); + } + + // 3. Create tasks with dependencies + // First pass: create all tasks, collect subject -> taskId mapping + const subjectToTaskId = {}; + + for (const taskDef of template.tasks) { + if (!roleToSessionId[taskDef.role]) { + log.push(`Skipping task "${taskDef.subject}" - no session for role "${taskDef.role}"`); + continue; + } + + const body = { + subject: taskDef.subject, + description: `Team: ${prefix} | Role: ${taskDef.role} | Goal: ${goal}`, + assigned_session_id: roleToSessionId[taskDef.role], + blocked_by: [], + }; + const res = await apiRequest('POST', '/api/tasks', body); + if (res.status !== 201) { + log.push(`Failed to create task "${taskDef.subject}": ${res.status}`); + continue; + } + subjectToTaskId[taskDef.subject] = res.data.task.id; + log.push(`Created task "${taskDef.subject}" (${res.data.task.id.slice(0, 8)}) assigned to ${taskDef.role}`); + } + + // Second pass: set blocked_by using subject -> taskId mapping + for (const taskDef of template.tasks) { + if (!subjectToTaskId[taskDef.subject]) continue; + if (!taskDef.blocked_by || taskDef.blocked_by.length === 0) continue; + + const blockedByIds = taskDef.blocked_by + .map(subject => subjectToTaskId[subject]) + .filter(Boolean); + + if (blockedByIds.length > 0) { + const taskId = subjectToTaskId[taskDef.subject]; + await apiRequest('PATCH', `/api/tasks/${taskId}`, { + blocked_by: blockedByIds, + status: 'blocked', + }); + log.push(`Set dependencies for "${taskDef.subject}": blocked by ${blockedByIds.map(id => id.slice(0, 8)).join(', ')}`); + } + } + + // 4. Send startup messages to unblocked tasks (those with no blocked_by) + for (const taskDef of template.tasks) { + if (taskDef.blocked_by && taskDef.blocked_by.length > 0) continue; + if (!roleToSessionId[taskDef.role]) continue; + if (!subjectToTaskId[taskDef.subject]) continue; + + const role = template.roles.find(r => r.name === taskDef.role); + if (!role) continue; + + const prompt = role.prompt_template + .replace(/\{\{goal\}\}/g, goal) + .replace(/\{\{goal_folder\}\}/g, goal_folder); + + const sessionId = roleToSessionId[taskDef.role]; + const taskId = subjectToTaskId[taskDef.subject]; + const text = `${prompt}\n\nTask ID: ${taskId}\n任务: ${taskDef.subject}`; + + const msgBody = { text }; + if (role.model) msgBody.model = role.model; + const res = await apiRequest('POST', `/api/sessions/${sessionId}/messages`, msgBody); + if (res.status === 202) { + log.push(`Sent startup message to "${taskDef.role}" (${sessionId.slice(0, 8)})`); + } else { + log.push(`Failed to send startup message to "${taskDef.role}": ${res.status}`); + } + } + + const summary = [ + `Team "${prefix}" launched from template "${template_name}"`, + `Roles: ${Object.keys(roleToSessionId).join(', ')}`, + `Tasks: ${Object.keys(subjectToTaskId).length} created`, + '', + ...log, + ].join('\n'); + + return { content: [{ type: 'text', text: summary }] }; +} diff --git a/static/chat/chat-base.css b/static/chat/chat-base.css index f449d05..11d3baa 100644 --- a/static/chat/chat-base.css +++ b/static/chat/chat-base.css @@ -206,6 +206,22 @@ body { } .header-share-btn:hover { background: var(--bg-secondary); color: var(--text); border-color: var(--border-strong); } .header-share-btn:disabled { opacity: 0.7; cursor: wait; } +.ops-trigger-btn { position: relative; } +.ops-report-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--error); + color: #fff; + font-size: 10px; + line-height: 16px; + font-weight: 700; + text-align: center; +} .header-refresh-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border-radius: 999px; @@ -249,6 +265,156 @@ body { } .session-list-footer.hidden { display: none; } +/* ---- Ops panel ---- */ +.ops-panel-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + z-index: 220; +} +.ops-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(460px, 100vw); + background: var(--bg); + border-left: 1px solid var(--border); + box-shadow: var(--modal-shadow); + z-index: 230; + display: flex; + flex-direction: column; + padding-top: var(--safe-top); +} +.ops-panel[hidden], +.ops-panel-backdrop[hidden] { + display: none !important; +} +.ops-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +.ops-panel-title { + font-size: 14px; + font-weight: 600; +} +.ops-panel-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} +.ops-panel-refresh { + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.ops-panel-refresh:hover { + border-color: var(--border-strong); + color: var(--text); +} +.ops-panel-tabs { + display: flex; + border-bottom: 1px solid var(--border); +} +.ops-panel-tab { + flex: 1; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + background: none; + padding: 10px 0; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; +} +.ops-panel-tab.active { + color: var(--text); + border-bottom-color: var(--text); +} +.ops-panel-body { + flex: 1; + min-height: 0; + overflow: hidden; +} +.ops-panel-view { + height: 100%; + overflow: auto; + padding: 10px; +} +.ops-list-empty { + padding: 16px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} +.ops-item { + border: 1px solid var(--border); + background: var(--bg-secondary); + border-radius: 10px; + padding: 10px; + margin-bottom: 10px; +} +.ops-item.unread { + border-color: var(--border-strong); +} +.ops-item-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} +.ops-item-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--text-muted); +} +.ops-item-text { + margin-top: 8px; + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} +.ops-item-actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.ops-item-btn { + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-secondary); + border-radius: 8px; + padding: 5px 9px; + font-size: 11px; + cursor: pointer; +} +.ops-item-btn:hover { + border-color: var(--border-strong); + color: var(--text); +} +.ops-trigger-enabled { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary); +} + /* ---- App container ---- */ .app-container { display: flex; diff --git a/static/chat/init.js b/static/chat/init.js index a4b157e..9cf1c49 100644 --- a/static/chat/init.js +++ b/static/chat/init.js @@ -9,6 +9,8 @@ function applyVisitorMode() { if (menuBtn) menuBtn.style.display = "none"; if (sortSessionListBtn) sortSessionListBtn.style.display = "none"; if (newSessionBtn) newSessionBtn.style.display = "none"; + const opsPanelBtn = document.getElementById("opsPanelBtn"); + if (opsPanelBtn) opsPanelBtn.style.display = "none"; // Hide tool/model selectors and context management (visitors use defaults) if (inlineToolSelect) inlineToolSelect.style.display = "none"; if (inlineModelSelect) inlineModelSelect.style.display = "none"; @@ -24,6 +26,9 @@ function applyVisitorMode() { } syncForkButton(); syncShareButton(); + if (window.RemoteLabOpsPanel && typeof window.RemoteLabOpsPanel.syncVisibilityForMode === "function") { + window.RemoteLabOpsPanel.syncVisibilityForMode(); + } } function applyShareSnapshotMode(snapshot) { diff --git a/static/chat/ops-ui.js b/static/chat/ops-ui.js new file mode 100644 index 0000000..7504b40 --- /dev/null +++ b/static/chat/ops-ui.js @@ -0,0 +1,248 @@ +(function () { + const opsPanelBtn = document.getElementById("opsPanelBtn"); + const opsReportBadge = document.getElementById("opsReportBadge"); + const opsPanel = document.getElementById("opsPanel"); + const opsPanelBackdrop = document.getElementById("opsPanelBackdrop"); + const opsPanelCloseBtn = document.getElementById("opsPanelCloseBtn"); + const opsPanelRefreshBtn = document.getElementById("opsPanelRefreshBtn"); + const opsTabReports = document.getElementById("opsTabReports"); + const opsTabTriggers = document.getElementById("opsTabTriggers"); + const opsReportsView = document.getElementById("opsReportsView"); + const opsTriggersView = document.getElementById("opsTriggersView"); + + if (!opsPanelBtn || !opsPanel || !opsPanelBackdrop || !opsReportsView || !opsTriggersView) { + return; + } + + let activeView = "reports"; + let pollTimer = null; + let reportsCache = []; + + function escapeHtml(value) { + const node = document.createElement("span"); + node.textContent = String(value || ""); + return node.innerHTML; + } + + async function apiJson(method, path, body) { + const options = { method }; + if (body !== undefined) { + options.headers = { "Content-Type": "application/json" }; + options.body = JSON.stringify(body); + } + const res = await fetch(path, options); + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + if (!res.ok) { + throw new Error((data && data.error) || `HTTP ${res.status}`); + } + return data; + } + + function formatTime(value) { + const ms = Date.parse(value || ""); + if (!Number.isFinite(ms)) return "-"; + return new Date(ms).toLocaleString(); + } + + function setReportBadge(reports) { + const unreadCount = (Array.isArray(reports) ? reports : []).filter((item) => !item?.read).length; + if (unreadCount <= 0) { + opsReportBadge.hidden = true; + opsReportBadge.textContent = "0"; + return; + } + opsReportBadge.hidden = false; + opsReportBadge.textContent = String(unreadCount); + } + + async function loadReports() { + const reports = await apiJson("GET", "/api/reports"); + reportsCache = Array.isArray(reports) ? reports : []; + setReportBadge(reportsCache); + return reportsCache; + } + + async function loadTriggers() { + const data = await apiJson("GET", "/api/triggers"); + return Array.isArray(data?.triggers) ? data.triggers : []; + } + + function renderReports(reports) { + if (!Array.isArray(reports) || reports.length === 0) { + opsReportsView.innerHTML = '
No reports yet.
'; + return; + } + + opsReportsView.innerHTML = reports.map((report) => { + const reportId = report?.id || ""; + return ` +
+
${escapeHtml(report?.title || "Untitled")}
+
+ ${escapeHtml(report?.source || "unknown")} + ${escapeHtml(formatTime(report?.createdAt))} + ${report?.read ? "read" : "unread"} +
+
+ + + +
+
+ `; + }).join(""); + } + + function renderTriggers(triggers) { + if (!Array.isArray(triggers) || triggers.length === 0) { + opsTriggersView.innerHTML = '
No triggers configured.
'; + return; + } + + opsTriggersView.innerHTML = triggers.map((trigger) => { + const text = String(trigger?.text || "").trim(); + return ` +
+
${escapeHtml(trigger?.title || "Untitled trigger")}
+
+ ${escapeHtml(trigger?.status || "unknown")} + ${escapeHtml(formatTime(trigger?.scheduledAt))} + ${escapeHtml((trigger?.sessionId || "").slice(0, 8))} +
+
${escapeHtml(text.length > 180 ? `${text.slice(0, 179)}...` : text)}
+
+ +
+
+ `; + }).join(""); + } + + async function refreshActiveView() { + try { + if (activeView === "reports") { + renderReports(await loadReports()); + return; + } + renderTriggers(await loadTriggers()); + } catch (error) { + const target = activeView === "reports" ? opsReportsView : opsTriggersView; + target.innerHTML = `
${escapeHtml(error.message || "Failed to load")}
`; + } + } + + function setOpsPanelOpen(open) { + opsPanel.hidden = !open; + opsPanelBackdrop.hidden = !open; + opsPanel.setAttribute("aria-hidden", open ? "false" : "true"); + + if (open) { + refreshActiveView(); + if (pollTimer) window.clearInterval(pollTimer); + pollTimer = window.setInterval(refreshActiveView, 15000); + return; + } + + if (pollTimer) { + window.clearInterval(pollTimer); + pollTimer = null; + } + } + + function switchView(nextView) { + activeView = nextView === "triggers" ? "triggers" : "reports"; + opsTabReports.classList.toggle("active", activeView === "reports"); + opsTabTriggers.classList.toggle("active", activeView === "triggers"); + opsReportsView.hidden = activeView !== "reports"; + opsTriggersView.hidden = activeView !== "triggers"; + if (!opsPanel.hidden) { + refreshActiveView(); + } + } + + async function handleReportAction(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const action = button.getAttribute("data-action"); + const card = button.closest("[data-report-id]"); + const reportId = card ? card.getAttribute("data-report-id") : ""; + if (!reportId) return; + + try { + button.disabled = true; + if (action === "open") { + const report = reportsCache.find((item) => item?.id === reportId); + if (report && !report.read) { + await apiJson("PATCH", `/api/reports/${encodeURIComponent(reportId)}/read`); + } + window.open(`/api/reports/${encodeURIComponent(reportId)}/html`, "_blank", "noopener"); + } else if (action === "mark-read") { + await apiJson("PATCH", `/api/reports/${encodeURIComponent(reportId)}/read`); + } else if (action === "delete") { + await apiJson("DELETE", `/api/reports/${encodeURIComponent(reportId)}`); + } + renderReports(await loadReports()); + } catch (error) { + button.disabled = false; + window.alert(error.message || "Operation failed"); + } + } + + async function handleTriggerAction(event) { + const toggle = event.target.closest('input[data-action="toggle-enabled"]'); + if (!toggle) return; + const card = toggle.closest("[data-trigger-id]"); + const triggerId = card ? card.getAttribute("data-trigger-id") : ""; + if (!triggerId) return; + + try { + toggle.disabled = true; + await apiJson("PATCH", `/api/triggers/${encodeURIComponent(triggerId)}`, { + enabled: !!toggle.checked, + }); + renderTriggers(await loadTriggers()); + } catch (error) { + toggle.disabled = false; + window.alert(error.message || "Update failed"); + } + } + + function syncVisibilityForMode() { + if (typeof visitorMode !== "undefined" && visitorMode) { + opsPanelBtn.style.display = "none"; + setOpsPanelOpen(false); + return; + } + opsPanelBtn.style.display = ""; + } + + opsPanelBtn.addEventListener("click", () => { + syncVisibilityForMode(); + if (opsPanelBtn.style.display === "none") return; + setOpsPanelOpen(opsPanel.hidden); + }); + if (opsPanelCloseBtn) opsPanelCloseBtn.addEventListener("click", () => setOpsPanelOpen(false)); + if (opsPanelBackdrop) opsPanelBackdrop.addEventListener("click", () => setOpsPanelOpen(false)); + if (opsPanelRefreshBtn) opsPanelRefreshBtn.addEventListener("click", () => refreshActiveView()); + if (opsTabReports) opsTabReports.addEventListener("click", () => switchView("reports")); + if (opsTabTriggers) opsTabTriggers.addEventListener("click", () => switchView("triggers")); + opsReportsView.addEventListener("click", handleReportAction); + opsTriggersView.addEventListener("change", handleTriggerAction); + + syncVisibilityForMode(); + void loadReports().catch(() => {}); + + window.RemoteLabOpsPanel = { + refresh: refreshActiveView, + close: () => setOpsPanelOpen(false), + syncVisibilityForMode, + }; +})(); diff --git a/team-templates.json b/team-templates.json new file mode 100644 index 0000000..73a3387 --- /dev/null +++ b/team-templates.json @@ -0,0 +1,30 @@ +{ + "software-dev": { + "description": "Software development team with leader + backend + frontend + tester", + "roles": [ + { "name": "leader", "folder": "{{goal_folder}}", "tool": "claude", "model": "opus", "prompt_template": "你是开发团队的 leader。项目目标:{{goal}}\n\n请先分析需求,设计方案,然后协调其他 agent 完成开发。" }, + { "name": "backend", "folder": "{{goal_folder}}", "tool": "claude", "model": "opus", "prompt_template": "你是后端开发者。项目目标:{{goal}}\n\n等待 leader 的任务分配。" }, + { "name": "frontend", "folder": "{{goal_folder}}", "tool": "claude", "model": "opus", "prompt_template": "你是前端开发者。项目目标:{{goal}}\n\n等待 leader 的任务分配。" }, + { "name": "tester", "folder": "{{goal_folder}}", "tool": "claude", "model": "sonnet", "prompt_template": "你是测试工程师。项目目标:{{goal}}\n\n等待开发完成后进行测试。" } + ], + "tasks": [ + { "subject": "需求分析与方案设计", "role": "leader", "blocked_by": [] }, + { "subject": "后端实现", "role": "backend", "blocked_by": ["需求分析与方案设计"] }, + { "subject": "前端实现", "role": "frontend", "blocked_by": ["需求分析与方案设计"] }, + { "subject": "集成测试", "role": "tester", "blocked_by": ["后端实现", "前端实现"] } + ] + }, + "research": { + "description": "Research team with researcher + analyzer + writer", + "roles": [ + { "name": "researcher", "folder": "{{goal_folder}}", "tool": "claude", "model": "opus", "prompt_template": "你是研究员。研究目标:{{goal}}\n\n进行深入调研,收集关键信息。" }, + { "name": "analyzer", "folder": "{{goal_folder}}", "tool": "claude", "model": "opus", "prompt_template": "你是分析师。研究目标:{{goal}}\n\n等待研究员提供数据后进行分析。" }, + { "name": "writer", "folder": "{{goal_folder}}", "tool": "claude", "model": "sonnet", "prompt_template": "你是报告撰写者。研究目标:{{goal}}\n\n等待分析完成后撰写报告。" } + ], + "tasks": [ + { "subject": "深入调研", "role": "researcher", "blocked_by": [] }, + { "subject": "数据分析", "role": "analyzer", "blocked_by": ["深入调研"] }, + { "subject": "报告撰写", "role": "writer", "blocked_by": ["数据分析"] } + ] + } +} diff --git a/templates/chat.html b/templates/chat.html index 30392a6..de69b90 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -31,6 +31,11 @@

RemoteLab Chat

Share + {{STATUS_TEXT}} @@ -136,6 +141,25 @@

RemoteLab Chat

+ + +