3 Commits

Author SHA1 Message Date
20f67e0177 feat: separate-routes 2026-03-03 19:25:42 -08:00
d4bbe42f3e fix: resolve duplicate endpoint definitions and nested route bug 2026-03-03 16:33:01 -08:00
9af9e7b4d7 feat: add agent dropdown for task assignment (#1)
Co-authored-by: Christopher Mayor <toph.homelab@gmail.com>
Co-committed-by: Christopher Mayor <toph.homelab@gmail.com>
2026-03-04 00:25:37 +00:00
11 changed files with 3165 additions and 244 deletions

38
TASK.md Normal file
View File

@@ -0,0 +1,38 @@
# Task: Separate Routes for Each Section
## Current State
- Single page app with client-side navigation
- Navigation links: Tasks, Wiki, Agents, Usage
- All content in one index.html
## Required Changes
1. Add Express routes in server.js:
- GET /tasks - Renders tasks page with kanban board
- GET /wiki - Renders wiki page with documentation list
- GET /agents - Renders agents page with agent cards
- GET /usage - Renders usage page with provider info
- GET / - Redirect to /tasks or render tasks page
2. Create separate HTML templates or use template rendering:
- Each page should have its own route
- Navigation should use standard anchor links (href=/tasks, etc.)
- Remove client-side navigation JavaScript
3. Keep API endpoints unchanged:
- /api/tasks, /api/wiki, /api/agents, /api/usage remain as JSON APIs
- Pages can fetch data client-side from these APIs after loading
4. Remove SPA logic from app.js:
- Remove nav-link click handlers
- Remove page switching logic
- Keep only data loading for each page
## Files to Modify
- server.js - Add route handlers for each page
- public/index.html - Either split or make it a template
- public/app.js - Remove SPA navigation, keep data loading
## After Completion
git add -A
git commit -m "feat: separate routes for each section"
git push origin feat/separate-routes

2322
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
public/agents.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link active">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>🤖 Agents</h2>
<p>Fleet agent workspace and configuration</p>
</header>
<div class="agents-grid" id="agents-grid"></div>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,64 +1,3 @@
function getPreferredTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-toggle').textContent = '☀️';
} else {
document.documentElement.removeAttribute('data-theme');
document.getElementById('theme-toggle').textContent = '🌙';
}
localStorage.setItem('theme', theme);
}
setTheme(getPreferredTheme());
document.getElementById('theme-toggle').addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
// Navigation
const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetPage = link.dataset.page;
// Update active nav link
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Show target page
pages.forEach(page => {
page.classList.remove('active');
if (page.id === `page-${targetPage}`) {
page.classList.add('active');
}
});
// Load page data
if (targetPage === 'wiki') loadWiki();
if (targetPage === 'agents') loadAgents();
if (targetPage === 'usage') loadUsage();
});
});
// Task Dashboard
const COLUMNS = {
'Backlog': { title: '📋 Backlog', tasks: [] },
@@ -292,8 +231,21 @@ function escapeHtml(text) {
return div.innerHTML;
}
// Initialize
loadTasks();
// Initialize based on current page
const path = window.location.pathname;
if (path === '/tasks' || path === '/') {
loadTasks();
} else if (path === '/wiki') {
loadWiki();
} else if (path === '/agents') {
loadAgents();
} else if (path === '/usage') {
loadUsage();
}
// Populate agent dropdown
populateAgentDropdown();
// WebSocket for real-time updates
const ws = new WebSocket(`ws://${window.location.host}`);
@@ -303,3 +255,27 @@ ws.onmessage = (event) => {
loadTasks();
}
};
// Populate agent dropdown
async function populateAgentDropdown() {
try {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
// Clear existing options except the first placeholder
select.innerHTML = '<option value="">Select agent...</option>';
// Add agent options
agents.forEach(agent => {
const option = document.createElement('option');
option.value = agent.name;
option.textContent = agent.name;
select.appendChild(option);
});
} catch (err) {
console.error('Failed to load agents for dropdown:', err);
}
}

View File

@@ -16,7 +16,6 @@
<a href="#" class="nav-link" data-page="wiki">📚 Wiki</a>
<a href="#" class="nav-link" data-page="agents">🤖 Agents</a>
<a href="#" class="nav-link" data-page="usage">📊 Usage</a>
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌙</button>
</div>
</nav>
@@ -31,7 +30,9 @@
<h3>Create Task</h3>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<input id="assignee" name="assignee" placeholder="Assignee agent" />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>

View File

@@ -5,20 +5,6 @@
}
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-card: #ffffff;
--text-primary: #24292f;
--text-secondary: #57606a;
--accent: #f78166;
--border: #d0d7de;
--priority-high: #f85149;
--priority-medium: #d29922;
--priority-low: #3fb950;
--priority-critical: #ff6b6b;
}
[data-theme="dark"] {
--bg-primary: #0f1419;
--bg-secondary: #1a1f2e;
--bg-card: #242b3d;
@@ -37,7 +23,6 @@ body {
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Navigation */
@@ -51,7 +36,6 @@ body {
position: sticky;
top: 0;
z-index: 1000;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.nav-brand h1 {
@@ -82,22 +66,6 @@ body {
color: white;
}
.theme-toggle {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 1.2rem;
transition: all 0.2s;
}
.theme-toggle:hover {
background: var(--accent);
color: white;
}
/* Pages */
.page {
display: none;
@@ -112,7 +80,6 @@ body {
padding: 1.5rem 2rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.topbar h2 {
@@ -130,7 +97,6 @@ body {
margin: 1rem;
border-radius: 8px;
border: 1px solid var(--border);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.composer h3 {
@@ -188,7 +154,6 @@ body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 250px);
transition: background-color 0.3s ease;
}
.column-header {
@@ -224,7 +189,6 @@ body {
margin-bottom: 0.5rem;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.card:hover {
@@ -323,7 +287,6 @@ body {
padding: 1rem;
overflow-y: auto;
max-height: calc(100vh - 220px);
transition: background-color 0.3s ease;
}
.wiki-item {
@@ -358,7 +321,6 @@ body {
padding: 2rem;
overflow-y: auto;
max-height: calc(100vh - 220px);
transition: background-color 0.3s ease;
}
.wiki-content h1 {
@@ -402,7 +364,6 @@ body {
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.agent-header {
@@ -474,7 +435,6 @@ body {
border-radius: 8px;
border: 1px solid var(--border);
padding: 1.5rem;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.provider-name {
@@ -569,3 +529,30 @@ body {
max-height: none;
}
}
/* Agent Dropdown Styling */
#task-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
background: var(--bg);
color: var(--fg);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#task-form select:hover {
border-color: var(--primary);
}
#task-form select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
#task-form select option {
padding: 0.5rem;
}

59
public/tasks.html Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tasks - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link active">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>Task Dashboard</h2>
<p>Real-time task coordination board</p>
</header>
<section class="composer">
<h3>Create Task</h3>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<select id="status" name="status">
<option selected>Backlog</option>
<option>Todo</option>
<option>In Progress</option>
<option>Review</option>
<option>Done</option>
</select>
<input id="tags" name="tags" placeholder="tags, comma, separated" />
<textarea id="description" name="description" placeholder="Description"></textarea>
<button type="submit">Add Task</button>
</form>
</section>
<main id="board" class="board"></main>
</div>
<script src="/app.js"></script>
</body>
</html>

32
public/usage.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Usage - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link active">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>📊 Usage & Quotas</h2>
<p>Provider models, quotas, and limits</p>
</header>
<div class="usage-container" id="usage-container"></div>
</div>
<script src="/app.js"></script>
</body>
</html>

37
public/wiki.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wiki - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link active">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>📚 Wiki</h2>
<p>Task documentation and implementation details</p>
</header>
<div class="wiki-container">
<div class="wiki-list" id="wiki-list"></div>
<div class="wiki-content" id="wiki-content">
<p>Select a wiki page to view documentation</p>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

204
server.js
View File

@@ -43,6 +43,26 @@ const wss = new WebSocketServer({ server });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/tasks', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'tasks.html'));
});
app.get('/wiki', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'wiki.html'));
});
app.get('/agents', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'agents.html'));
});
app.get('/usage', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'usage.html'));
});
app.get('/', (req, res) => {
res.redirect('/tasks');
});
function normalizeTask(row) {
return {
...row,
@@ -122,6 +142,49 @@ app.get('/api/tasks', (req, res) => {
});
});
app.get('/api/wiki', (req, res) => {
try {
const files = [];
if (fs.existsSync(WIKI_DIR)) {
const wikiFiles = fs.readdirSync(WIKI_DIR).filter(f => f.endsWith('.md'));
wikiFiles.forEach(filename => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
files.push({
filename,
created: stats.mtime.toISOString()
});
});
files.sort((a, b) => new Date(b.created) - new Date(a.created));
}
res.json(files);
} catch (err) {
console.error('Error reading wiki:', err);
res.status(500).json({ error: 'failed_to_fetch_wiki' });
}
});
app.get('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
const content = fs.readFileSync(filePath, 'utf8');
res.json({ content });
} catch (err) {
console.error('Error reading wiki page:', err);
res.status(500).json({ error: 'failed_to_fetch_wiki_page' });
}
});
app.post('/api/tasks', (req, res) => {
const errors = validatePayload(req.body, false);
if (errors.length) {
@@ -229,152 +292,15 @@ app.patch('/api/tasks/:id', (req, res) => {
} catch (wikiErr) {
console.error('wiki_creation_error', wikiErr);
}
// Agents endpoint
app.get('/api/agents', (req, res) => {
try {
const agents = [];
if (fs.existsSync(AGENTS_DIR)) {
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
const agent = {
name: agentName,
status: 'active',
currentTask: null,
tools: [],
files: [],
permissions: []
};
// Read workspace files
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
// Read MEMORY.md for tools
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
// Extract tools from memory
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\s*/, '').trim());
}
}
// Read HEARTBEAT.md for current task
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
}
}
agents.push(agent);
});
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
providers: [],
lastUpdated: new Date().toISOString()
};
// Read OpenClaw config
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
// Extract provider information
if (config.models) {
const providerMap = {};
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
const provider = modelConfig.provider || 'unknown';
if (!providerMap[provider]) {
providerMap[provider] = {
name: provider,
models: [],
quota: {
requests: 0,
tokens: 0,
limit: 'unlimited'
}
};
}
providerMap[provider].models.push({
name: modelName,
type: modelConfig.type || 'chat',
contextWindow: modelConfig.context_window || 'unknown'
});
});
usage.providers = Object.values(providerMap);
}
}
res.json(usage);
} catch (err) {
console.error('Error reading usage:', err);
res.status(500).json({ error: 'failed_to_fetch_usage' });
}
});
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
db.all(
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
[agent, 'Todo', 'In Progress', 'Review'],
(err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
const tasks = rows.map(normalizeTask);
res.json({
agent,
pending_tasks: tasks.length,
tasks
});
}
);
});
}
broadcast('task_updated', task);
return res.json(task);
});
}
);
});
});
});
});
// Agents endpoint
app.get('/api/agents', (req, res) => {
try {
const agents = [];

511
server.js.bak Normal file
View File

@@ -0,0 +1,511 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const http = require('http');
const sqlite3 = require('sqlite3').verbose();
const { WebSocketServer } = require('ws');
const PORT = process.env.PORT || 8395;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki';
const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical'];
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
fs.mkdirSync(WIKI_DIR, { recursive: true });
const db = new sqlite3.Database(DB_PATH);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
assignee TEXT DEFAULT '',
priority TEXT NOT NULL DEFAULT 'Medium',
status TEXT NOT NULL DEFAULT 'Backlog',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
});
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
function normalizeTask(row) {
return {
...row,
tags: (() => {
try {
return JSON.parse(row.tags || '[]');
} catch {
return [];
}
})(),
};
}
function writeWiki(task) {
const safeTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `task-${task.id}`;
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`;
const filePath = path.join(WIKI_DIR, fileName);
const md = `# ${task.title}\n\n` +
`- Task ID: ${task.id}\n` +
`- Assignee: ${task.assignee || 'Unassigned'}\n` +
`- Priority: ${task.priority}\n` +
`- Status: ${task.status}\n` +
`- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` +
`- Created: ${task.created_at}\n` +
`- Completed: ${task.completed_at || new Date().toISOString()}\n\n` +
`## Description\n\n${task.description || 'No description provided.'}\n`;
fs.writeFileSync(filePath, md, 'utf8');
}
function broadcast(type, payload) {
const data = JSON.stringify({ type, payload });
for (const client of wss.clients) {
if (client.readyState === 1) {
client.send(data);
}
}
}
function validatePayload(body, partial = false) {
const errors = [];
if (!partial || body.title !== undefined) {
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
errors.push('title is required');
}
}
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
}
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
}
if (body.tags !== undefined && !Array.isArray(body.tags)) {
errors.push('tags must be an array of strings');
}
return errors;
}
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
return res.json(rows.map(normalizeTask));
});
});
app.post('/api/tasks', (req, res) => {
const errors = validatePayload(req.body, false);
if (errors.length) {
return res.status(400).json({ error: 'validation_error', details: errors });
}
const title = req.body.title.trim();
const description = typeof req.body.description === 'string' ? req.body.description : '';
const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : '';
const priority = req.body.priority || 'Medium';
const status = req.body.status || 'Backlog';
const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : [];
db.run(
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
VALUES (?, ?, ?, ?, ?, ?)`,
[title, description, assignee, priority, status, JSON.stringify(tags)],
function onInsert(err) {
if (err) {
return res.status(500).json({ error: 'failed_to_create_task' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_created_task' });
}
const task = normalizeTask(row);
broadcast('task_created', task);
return res.status(201).json(task);
});
}
);
});
app.patch('/api/tasks/:id', (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'invalid_task_id' });
}
const errors = validatePayload(req.body, true);
if (errors.length) {
return res.status(400).json({ error: 'validation_error', details: errors });
}
db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => {
if (err) {
return res.status(500).json({ error: 'failed_to_find_task' });
}
if (!existing) {
return res.status(404).json({ error: 'task_not_found' });
}
const existingTask = normalizeTask(existing);
const next = {
title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title,
description: req.body.description !== undefined ? String(req.body.description) : existingTask.description,
assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee,
priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority,
status: req.body.status !== undefined ? req.body.status : existingTask.status,
tags: req.body.tags !== undefined
? req.body.tags.filter((t) => typeof t === 'string')
: existingTask.tags,
};
const nowDone = next.status === 'Done';
const wasDone = existingTask.status === 'Done';
const completedAt = nowDone && !wasDone
? new Date().toISOString()
: nowDone
? existing.completed_at
: null;
db.run(
`UPDATE tasks
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
completed_at = ?, updated_at = datetime('now')
WHERE id = ?`,
[
next.title,
next.description,
next.assignee,
next.priority,
next.status,
JSON.stringify(next.tags),
completedAt,
id,
],
(updateErr) => {
if (updateErr) {
return res.status(500).json({ error: 'failed_to_update_task' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_updated_task' });
}
const task = normalizeTask(row);
if (nowDone && !wasDone) {
try {
writeWiki(task);
} catch (wikiErr) {
console.error('wiki_creation_error', wikiErr);
}
// Agents endpoint
app.get('/api/agents', (req, res) => {
try {
const agents = [];
if (fs.existsSync(AGENTS_DIR)) {
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
const agent = {
name: agentName,
status: 'active',
currentTask: null,
tools: [],
files: [],
permissions: []
};
// Read workspace files
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
// Read MEMORY.md for tools
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
// Extract tools from memory
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\s*/, '').trim());
}
}
// Read HEARTBEAT.md for current task
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
}
}
agents.push(agent);
});
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
providers: [],
lastUpdated: new Date().toISOString()
};
// Read OpenClaw config
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
// Extract provider information
if (config.models) {
const providerMap = {};
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
const provider = modelConfig.provider || 'unknown';
if (!providerMap[provider]) {
providerMap[provider] = {
name: provider,
models: [],
quota: {
requests: 0,
tokens: 0,
limit: 'unlimited'
}
};
}
providerMap[provider].models.push({
name: modelName,
type: modelConfig.type || 'chat',
contextWindow: modelConfig.context_window || 'unknown'
});
});
usage.providers = Object.values(providerMap);
}
}
res.json(usage);
} catch (err) {
console.error('Error reading usage:', err);
res.status(500).json({ error: 'failed_to_fetch_usage' });
}
});
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
db.all(
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
[agent, 'Todo', 'In Progress', 'Review'],
(err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
const tasks = rows.map(normalizeTask);
res.json({
agent,
pending_tasks: tasks.length,
tasks
});
}
);
});
}
broadcast('task_updated', task);
return res.json(task);
});
}
);
});
});
// Agents endpoint
app.get('/api/agents', (req, res) => {
try {
const agents = [];
if (fs.existsSync(AGENTS_DIR)) {
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
const agent = {
name: agentName,
status: 'active',
currentTask: null,
tools: [],
files: [],
permissions: []
};
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\\s*/, '').trim());
}
}
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
}
}
agents.push(agent);
});
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
providers: [],
lastUpdated: new Date().toISOString()
};
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
if (config.models) {
const providerMap = {};
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
const provider = modelConfig.provider || 'unknown';
if (!providerMap[provider]) {
providerMap[provider] = {
name: provider,
models: [],
quota: {
requests: 0,
tokens: 0,
limit: 'unlimited'
}
};
}
providerMap[provider].models.push({
name: modelName,
type: modelConfig.type || 'chat',
contextWindow: modelConfig.context_window || 'unknown'
});
});
usage.providers = Object.values(providerMap);
}
}
res.json(usage);
} catch (err) {
console.error('Error reading usage:', err);
res.status(500).json({ error: 'failed_to_fetch_usage' });
}
});
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
db.all(
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
[agent, 'Todo', 'In Progress', 'Review'],
(err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
const tasks = rows.map(normalizeTask);
res.json({
agent,
pending_tasks: tasks.length,
tasks
});
}
);
});
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`openclaw-taskboard listening on ${PORT}`);
});