diff --git a/public/app.js b/public/app.js
index fd111f6..c942107 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,301 +1,730 @@
-// Navigation
+// ============ 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');
- }
+ 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();
});
-
- // Load page data
- if (targetPage === 'wiki') loadWiki();
- if (targetPage === 'agents') loadAgents();
- if (targetPage === 'usage') loadUsage();
- });
});
-// Task Dashboard
+// ============ TASK DASHBOARD ============
const COLUMNS = {
- 'Backlog': { title: '📋 Backlog', tasks: [] },
- 'Todo': { title: '📝 Todo', tasks: [] },
- 'In Progress': { title: '🔄 In Progress', tasks: [] },
- 'Review': { title: '👀 Review', tasks: [] },
- 'Done': { title: '✅ Done', tasks: [] }
+ 'Backlog': { title: '📋 Backlog', tasks: [] },
+ 'Todo': { title: '📝 Todo', tasks: [] },
+ 'In Progress': { title: '🔄 In Progress', tasks: [] },
+ 'Review': { title: '👀 Review', tasks: [] },
+ 'Done': { title: '✅ Done', tasks: [] }
};
async function loadTasks() {
- const res = await fetch('/api/tasks');
- const tasks = await res.json();
-
- // Reset columns
- Object.keys(COLUMNS).forEach(status => {
- COLUMNS[status].tasks = [];
- });
-
- // Group tasks by status
- tasks.forEach(task => {
- if (COLUMNS[task.status]) {
- COLUMNS[task.status].tasks.push(task);
- }
- });
-
- renderBoard();
+ const res = await fetch('/api/tasks');
+ const tasks = await res.json();
+
+ // Reset columns
+ Object.keys(COLUMNS).forEach(status => {
+ COLUMNS[status].tasks = [];
+ });
+
+ // Group tasks by status
+ tasks.forEach(task => {
+ if (COLUMNS[task.status]) {
+ COLUMNS[task.status].tasks.push(task);
+ }
+ });
+
+ renderBoard();
}
function renderBoard() {
- const board = document.getElementById('board');
- board.innerHTML = '';
-
- Object.entries(COLUMNS).forEach(([status, column]) => {
- const columnEl = document.createElement('div');
- columnEl.className = 'column';
+ const board = document.getElementById('board');
+ board.innerHTML = '';
- columnEl.innerHTML = `
-
+
-
-
-
+
+
+
+
+
Create Task
+
+
-
+
+
+
+
+
+
🔄 In Progress
+ 0
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
📚 Select a wiki page from the sidebar or create a new one.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Estimated Cost
+
$0.00
+
+
+
+
+
+
Usage by Provider
+
+
+
+
Usage by Agent
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
Select a wiki page to view documentation
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/public/index.html.backup b/public/index.html.backup
new file mode 100644
index 0000000..599e418
--- /dev/null
+++ b/public/index.html.backup
@@ -0,0 +1,97 @@
+
+
+
+
+
+
OpenClaw Agent Fleet Dashboard
+
+
+
+
+
+
+
+
+
+
Create Task
+
+
+
+
+
+
+
+
+
🔄 In Progress
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index e5512dd..6fe3a33 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1,558 +1,999 @@
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
+:root {
+ --bg: #1a1a1a;
+ --fg: #e0e0e0;
+ --border: #444;
+ --primary: #3498db;
+ --secondary: #2ecc71;
+ --danger: #e74c3c;
+ --warning: #f39c12;
+ --dark: #121212;
+ --light: #f0f0f0;
+ --card-bg: #2a2a2a;
+ --card-fg: #e0e0e0;
+ --modal-bg: rgba(0, 0, 0, 0.7);
}
-:root {
- --bg-primary: #0f1419;
- --bg-secondary: #1a1f2e;
- --bg-card: #242b3d;
- --text-primary: #e6edf3;
- --text-secondary: #8b949e;
- --accent: #f78166;
- --border: #30363d;
- --priority-high: #f85149;
- --priority-medium: #d29922;
- --priority-low: #3fb950;
- --priority-critical: #ff6b6b;
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- background: var(--bg-primary);
- color: var(--text-primary);
- min-height: 100vh;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: var(--bg);
+ color: var(--fg);
+ line-height: 1.6;
}
-/* Navigation */
-.navbar {
- background: var(--bg-secondary);
- padding: 1rem 2rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid var(--border);
- position: sticky;
- top: 0;
- z-index: 1000;
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
}
-.nav-brand h1 {
- font-size: 1.5rem;
- color: var(--text-primary);
+header {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
-.nav-links {
- display: flex;
- gap: 1rem;
+header h1 {
+ color: var(--primary);
+ margin-bottom: 15px;
+}
+
+nav {
+ display: flex;
+ gap: 15px;
}
.nav-link {
- color: var(--text-secondary);
- text-decoration: none;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- transition: all 0.2s;
+ color: var(--fg);
+ text-decoration: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ transition: background-color 0.3s ease;
}
.nav-link:hover {
- background: var(--bg-card);
- color: var(--text-primary);
+ background-color: var(--border);
}
.nav-link.active {
- background: var(--accent);
- color: white;
+ background-color: var(--primary);
+ color: white;
}
-/* Pages */
-.page {
- display: none;
+/* Buttons */
+.btn-primary {
+ background-color: var(--primary);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
}
-.page.active {
- display: block;
+.btn-primary:hover {
+ background-color: #2980b9;
}
-/* Dashboard */
-.topbar {
- padding: 1.5rem 2rem;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
+.btn-secondary {
+ background-color: var(--border);
+ color: var(--fg);
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
}
-.topbar h2 {
- font-size: 1.5rem;
- margin-bottom: 0.5rem;
+.btn-secondary:hover {
+ background-color: #555;
}
-.topbar p {
- color: var(--text-secondary);
+.btn-danger {
+ background-color: var(--danger);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: background-color 0.3s ease;
}
+.btn-danger:hover {
+ background-color: #c0392b;
+}
+
+/* ============ TASKS PAGE ============ */
.composer {
- padding: 1.5rem 2rem;
- background: var(--bg-secondary);
- margin: 1rem;
- border-radius: 8px;
- border: 1px solid var(--border);
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
-.composer h3 {
- margin-bottom: 1rem;
+.composer h2 {
+ color: var(--primary);
+ margin-bottom: 15px;
}
-.composer form {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 1rem;
+#task-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 15px;
}
-.composer input,
-.composer select,
-.composer textarea {
- padding: 0.5rem;
- background: var(--bg-card);
- border: 1px solid var(--border);
- color: var(--text-primary);
- border-radius: 4px;
+#task-form input,
+#task-form textarea,
+#task-form select {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-size: 1rem;
}
-.composer textarea {
- grid-column: 1 / -1;
- min-height: 80px;
+#task-form textarea {
+ grid-column: span 2;
+ resize: vertical;
+ min-height: 100px;
}
-.composer button {
- padding: 0.5rem 1.5rem;
- background: var(--accent);
- color: white;
- border: none;
- border-radius: 6px;
- cursor: pointer;
- font-size: 0.9rem;
+#task-form button {
+ grid-column: span 2;
+ padding: 12px 24px;
+ background-color: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
}
-.composer button:hover {
- opacity: 0.9;
+#task-form button:hover {
+ background-color: #2980b9;
}
-/* Board */
-.board {
- display: flex;
- gap: 1rem;
- padding: 1rem;
- overflow-x: auto;
- min-height: calc(100vh - 400px);
+#board {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 20px;
}
.column {
- flex: 0 0 280px;
- background: var(--bg-secondary);
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 250px);
+ background-color: var(--card-bg);
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
}
.column-header {
- padding: 1rem;
- border-bottom: 1px solid var(--border);
- display: flex;
- justify-content: space-between;
- align-items: center;
+ padding: 15px;
+ background-color: var(--dark);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
.column-header h3 {
- font-size: 0.9rem;
- text-transform: uppercase;
+ color: var(--fg);
}
.column-count {
- background: var(--bg-card);
- padding: 0.25rem 0.5rem;
- border-radius: 12px;
- font-size: 0.8rem;
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.9rem;
}
.cards {
- flex: 1;
- overflow-y: auto;
- padding: 0.5rem;
+ padding: 15px;
+ min-height: 200px;
}
.card {
- background: var(--bg-card);
- border-radius: 6px;
- padding: 0.75rem;
- margin-bottom: 0.5rem;
- cursor: pointer;
- border: 1px solid transparent;
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 15px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
- border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.card-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 0.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
}
.card-title {
- font-weight: 500;
- font-size: 0.95rem;
+ color: var(--card-fg);
+ font-size: 1.1rem;
}
.badge {
- padding: 0.15rem 0.5rem;
- border-radius: 10px;
- font-size: 0.75rem;
- font-weight: 600;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: bold;
}
-.priority-High {
- background: var(--priority-high);
- color: white;
+.badge.priority-Low {
+ background-color: var(--secondary);
+ color: white;
}
-.priority-Medium {
- background: var(--priority-medium);
- color: white;
+.badge.priority-Medium {
+ background-color: var(--warning);
+ color: white;
}
-.priority-Low {
- background: var(--priority-low);
- color: white;
+.badge.priority-High {
+ background-color: var(--danger);
+ color: white;
}
-.priority-Critical {
- background: var(--priority-critical);
- color: white;
+.badge.priority-Critical {
+ background-color: #9c27b0;
+ color: white;
}
.card-desc {
- color: var(--text-secondary);
- font-size: 0.85rem;
- margin-bottom: 0.5rem;
+ color: var(--fg);
+ margin-bottom: 10px;
}
.meta {
- font-size: 0.75rem;
- color: var(--text-secondary);
- margin-bottom: 0.25rem;
+ font-size: 0.9rem;
+ color: var(--border);
+ margin-bottom: 5px;
}
-.assignee {
- color: var(--accent);
-}
-
-.tags {
- display: flex;
- gap: 0.25rem;
- flex-wrap: wrap;
+.meta.assignee {
+ font-weight: bold;
}
.tag {
- background: var(--bg-primary);
- padding: 0.15rem 0.5rem;
- border-radius: 4px;
- font-size: 0.7rem;
+ display: inline-block;
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 2px 6px;
+ border-radius: 4px;
+ margin-right: 5px;
+ margin-bottom: 5px;
}
-.card label {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-size: 0.75rem;
- color: var(--text-secondary);
- margin-top: 0.5rem;
+.card-check {
+ margin-right: 10px;
+}
+
+/* ============ WIKI PAGE ============ */
+#page-wiki {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
-/* Wiki */
.wiki-container {
- display: grid;
- grid-template-columns: 300px 1fr;
- gap: 1rem;
- padding: 1rem;
- min-height: calc(100vh - 200px);
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: 20px;
+ min-height: 600px;
+}
+
+.wiki-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.wiki-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.wiki-search input {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
}
.wiki-list {
- background: var(--bg-secondary);
- border-radius: 8px;
- padding: 1rem;
- overflow-y: auto;
- max-height: calc(100vh - 220px);
+ flex: 1;
+ overflow-y: auto;
+ max-height: 500px;
}
.wiki-item {
- padding: 0.75rem;
- border-radius: 6px;
- cursor: pointer;
- margin-bottom: 0.5rem;
- background: var(--bg-card);
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 10px;
+ cursor: pointer;
+ transition: all 0.2s ease;
}
.wiki-item:hover {
- background: var(--accent);
+ background-color: var(--dark);
+ border-color: var(--primary);
}
.wiki-item.active {
- background: var(--accent);
+ background-color: var(--primary);
+ border-color: var(--primary);
+}
+
+.wiki-item.active .wiki-title,
+.wiki-item.active .wiki-date {
+ color: white;
}
.wiki-title {
- font-size: 0.9rem;
- margin-bottom: 0.25rem;
+ color: var(--card-fg);
+ margin-bottom: 5px;
+ font-size: 0.95rem;
}
.wiki-date {
- font-size: 0.75rem;
- color: var(--text-secondary);
+ color: var(--border);
+ font-size: 0.8rem;
+}
+
+.wiki-main {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.wiki-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 15px;
+ border-bottom: 1px solid var(--border);
+}
+
+.wiki-page-title {
+ font-size: 1.3rem;
+ font-weight: bold;
+ color: var(--primary);
+}
+
+.wiki-page-actions {
+ display: flex;
+ gap: 10px;
}
.wiki-content {
- background: var(--bg-secondary);
- border-radius: 8px;
- padding: 2rem;
- overflow-y: auto;
- max-height: calc(100vh - 220px);
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+ overflow-y: auto;
+ flex: 1;
}
-.wiki-content h1 {
- margin-bottom: 1rem;
+.wiki-content h1, .wiki-content h2, .wiki-content h3 {
+ color: var(--primary);
+ margin-top: 20px;
+ margin-bottom: 10px;
}
-.wiki-content h2 {
- margin-top: 1.5rem;
- margin-bottom: 0.5rem;
+.wiki-content h1:first-child {
+ margin-top: 0;
}
.wiki-content p {
- margin-bottom: 1rem;
+ margin-bottom: 15px;
+}
+
+.wiki-content ul, .wiki-content ol {
+ margin-left: 20px;
+ margin-bottom: 15px;
}
.wiki-content code {
- background: var(--bg-card);
- padding: 0.2rem 0.4rem;
- border-radius: 3px;
- font-family: monospace;
+ background-color: var(--dark);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: 'Consolas', monospace;
}
.wiki-content pre {
- background: var(--bg-card);
- padding: 1rem;
- border-radius: 6px;
- overflow-x: auto;
- margin-bottom: 1rem;
+ background-color: var(--dark);
+ padding: 15px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin-bottom: 15px;
+}
+
+.wiki-content pre code {
+ padding: 0;
+}
+
+.wiki-content blockquote {
+ border-left: 3px solid var(--primary);
+ padding-left: 15px;
+ margin-left: 0;
+ color: #aaa;
+}
+
+.wiki-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 300px;
+ color: var(--border);
+ font-size: 1.1rem;
+}
+
+.wiki-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ flex: 1;
+}
+
+.wiki-editor input {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-size: 1.1rem;
+}
+
+.wiki-editor textarea {
+ flex: 1;
+ min-height: 400px;
+ padding: 15px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-family: 'Consolas', monospace;
+ font-size: 0.95rem;
+ resize: vertical;
+}
+
+.editor-actions {
+ display: flex;
+ gap: 10px;
+}
+
+/* ============ AGENTS PAGE ============ */
+#page-agents {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.agents-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.agents-header h2 {
+ color: var(--primary);
+}
+
+.agents-controls {
+ display: flex;
+ gap: 15px;
+}
+
+.agents-controls input,
+.agents-controls select {
+ padding: 10px 15px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
}
-/* Agents */
.agents-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
- gap: 1rem;
- padding: 1rem;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: 20px;
}
.agent-card {
- background: var(--bg-secondary);
- border-radius: 8px;
- border: 1px solid var(--border);
- overflow: hidden;
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 15px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.agent-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.agent-header {
- background: var(--bg-card);
- padding: 1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
}
.agent-name {
- font-size: 1.1rem;
+ color: var(--card-fg);
+ font-size: 1.2rem;
}
.agent-status {
- padding: 0.25rem 0.75rem;
- border-radius: 12px;
- font-size: 0.75rem;
- background: var(--priority-low);
- color: white;
+ padding: 4px 10px;
+ border-radius: 20px;
+ font-size: 0.8rem;
+ font-weight: bold;
}
-.agent-body {
- padding: 1rem;
+.agent-status.status-active {
+ background-color: var(--secondary);
+ color: white;
+}
+
+.agent-status.status-busy {
+ background-color: var(--warning);
+ color: white;
+}
+
+.agent-status.status-idle {
+ background-color: var(--border);
+ color: var(--fg);
+}
+
+.agent-workload {
+ margin-bottom: 15px;
+}
+
+.workload-badge {
+ background-color: var(--primary);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.85rem;
}
.agent-section {
- margin-bottom: 1rem;
+ margin-bottom: 15px;
}
.agent-section h4 {
- font-size: 0.85rem;
- color: var(--text-secondary);
- margin-bottom: 0.5rem;
+ color: var(--primary);
+ margin-bottom: 8px;
+ font-size: 0.95rem;
}
.agent-task {
- color: var(--text-primary);
- font-size: 0.9rem;
+ color: var(--fg);
}
.agent-tools,
.agent-files {
- display: flex;
- flex-wrap: wrap;
- gap: 0.25rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
}
.tool-tag,
.file-tag {
- background: var(--bg-card);
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75rem;
- color: var(--text-secondary);
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
}
-/* Usage */
-.usage-container {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
- gap: 1rem;
- padding: 1rem;
+.capability-tag {
+ background-color: var(--secondary);
+ color: white;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
}
-.usage-card {
- background: var(--bg-secondary);
- border-radius: 8px;
- border: 1px solid var(--border);
- padding: 1.5rem;
+.more-tag {
+ background-color: var(--dark);
+ color: var(--fg);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
}
-.provider-name {
- font-size: 1.2rem;
- margin-bottom: 1rem;
- color: var(--accent);
+.no-data {
+ color: var(--border);
+ font-style: italic;
}
-.provider-models {
- margin-bottom: 1rem;
+.agent-actions {
+ display: flex;
+ gap: 10px;
+ padding-top: 15px;
+ border-top: 1px solid var(--border);
+}
+
+.agent-actions button {
+ flex: 1;
+}
+
+/* ============ MODALS ============ */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: var(--modal-bg);
+ z-index: 1000;
+ justify-content: center;
+ align-items: center;
+}
+
+.modal.active {
+ display: flex;
+}
+
+.modal-content {
+ background-color: var(--card-bg);
+ border-radius: 12px;
+ max-width: 600px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h3 {
+ color: var(--primary);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--fg);
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 0 5px;
+}
+
+.modal-close:hover {
+ color: var(--danger);
+}
+
+.modal-body {
+ padding: 20px;
+}
+
+.detail-section {
+ margin-bottom: 20px;
+}
+
+.detail-section h4 {
+ color: var(--primary);
+ margin-bottom: 10px;
+ font-size: 1rem;
+}
+
+.task-list {
+ list-style: none;
+ padding: 0;
+}
+
+.task-list li {
+ padding: 8px 12px;
+ background-color: var(--bg);
+ border-radius: 6px;
+ margin-bottom: 5px;
+}
+
+.tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+#assign-task-select {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ margin-bottom: 15px;
+}
+
+#confirm-assign-btn {
+ width: 100%;
+}
+
+/* ============ USAGE PAGE ============ */
+#page-usage {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.usage-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.usage-header h2 {
+ color: var(--primary);
+}
+
+.usage-controls {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.date-range {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.date-range label {
+ font-size: 0.9rem;
+}
+
+.date-range input {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+.export-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.usage-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.stat-card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+ text-align: center;
+}
+
+.stat-card h4 {
+ color: var(--border);
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+}
+
+.stat-card .stat-value {
+ color: var(--primary);
+ font-size: 2rem;
+ font-weight: bold;
+}
+
+.usage-charts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.chart-container {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+}
+
+.chart-container h4 {
+ color: var(--fg);
+ margin-bottom: 15px;
+}
+
+.usage-details h3 {
+ color: var(--primary);
+ margin-bottom: 20px;
+}
+
+.usage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.provider-card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+}
+
+.provider-card h4 {
+ color: var(--card-fg);
+ margin-bottom: 15px;
+ font-size: 1.1rem;
+}
+
+.provider-stats {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 15px;
+}
+
+.provider-stat {
+ display: flex;
+ flex-direction: column;
+}
+
+.provider-stat .stat-label {
+ color: var(--border);
+ font-size: 0.8rem;
+}
+
+.provider-stat .stat-value {
+ color: var(--fg);
+ font-size: 1.1rem;
+ font-weight: bold;
+}
+
+.model-list h5 {
+ color: var(--primary);
+ margin-bottom: 10px;
+ font-size: 0.9rem;
}
.model-item {
- padding: 0.5rem;
- background: var(--bg-card);
- border-radius: 4px;
- margin-bottom: 0.5rem;
+ display: flex;
+ justify-content: space-between;
+ background-color: var(--dark);
+ padding: 10px;
+ border-radius: 6px;
+ margin-bottom: 5px;
}
.model-name {
- font-weight: 500;
- margin-bottom: 0.25rem;
+ color: var(--primary);
+ font-weight: bold;
}
-.model-meta {
- font-size: 0.75rem;
- color: var(--text-secondary);
+.model-type {
+ color: var(--fg);
+ font-size: 0.9rem;
}
-.provider-quota {
- padding: 1rem;
- background: var(--bg-card);
- border-radius: 6px;
+/* ============ RESPONSIVE ============ */
+@media (max-width: 900px) {
+ .wiki-container {
+ grid-template-columns: 1fr;
+ }
+
+ .wiki-sidebar {
+ max-height: 200px;
+ }
+
+ .wiki-list {
+ max-height: 150px;
+ }
}
-.quota-title {
- font-weight: 600;
- margin-bottom: 0.5rem;
-}
-
-.quota-item {
- display: flex;
- justify-content: space-between;
- margin-bottom: 0.5rem;
- font-size: 0.9rem;
-}
-
-.quota-label {
- color: var(--text-secondary);
-}
-
-.quota-value {
- font-weight: 500;
-}
-
-.quota-bar {
- height: 8px;
- background: var(--bg-primary);
- border-radius: 4px;
- overflow: hidden;
- margin-top: 0.5rem;
-}
-
-.quota-fill {
- height: 100%;
- background: var(--accent);
- transition: width 0.3s;
-}
-
-/* Responsive */
@media (max-width: 768px) {
- .navbar {
- flex-direction: column;
- gap: 1rem;
- }
-
- .nav-links {
- width: 100%;
- justify-content: space-around;
- }
-
- .wiki-container {
- grid-template-columns: 1fr;
- }
-
- .board {
- flex-direction: column;
- }
-
- .column {
- flex: 0 0 auto;
- max-height: none;
- }
+ #task-form {
+ grid-template-columns: 1fr;
+ }
+
+ #task-form textarea {
+ grid-column: span 1;
+ }
+
+ #task-form button {
+ grid-column: span 1;
+ }
+
+ .usage-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .agents-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .agents-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .agents-controls {
+ width: 100%;
+ }
+
+ .agents-controls input {
+ flex: 1;
+ }
+
+ .usage-header {
+ flex-direction: column;
+ }
+
+ .usage-controls {
+ width: 100%;
+ flex-direction: column;
+ }
+
+ .date-range {
+ flex-wrap: wrap;
+ }
+
+ .usage-charts {
+ grid-template-columns: 1fr;
+ }
}
-/* 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;
+/* ============ SCROLLBAR ============ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
}
-#task-form select:hover {
- border-color: var(--primary);
+::-webkit-scrollbar-track {
+ background: var(--dark);
}
-#task-form select:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
}
-#task-form select option {
- padding: 0.5rem;
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
}
diff --git a/public/styles.css.backup b/public/styles.css.backup
new file mode 100644
index 0000000..330c089
--- /dev/null
+++ b/public/styles.css.backup
@@ -0,0 +1,451 @@
+:root {
+ --bg: #1a1a1a;
+ --fg: #e0e0e0;
+ --border: #444;
+ --primary: #3498db;
+ --secondary: #2ecc71;
+ --danger: #e74c3c;
+ --warning: #f39c12;
+ --dark: #121212;
+ --light: #f0f0f0;
+ --card-bg: #2a2a2a;
+ --card-fg: #e0e0e0;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: var(--bg);
+ color: var(--fg);
+ line-height: 1.6;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+header {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+header h1 {
+ color: var(--primary);
+ margin-bottom: 15px;
+}
+
+nav {
+ display: flex;
+ gap: 15px;
+}
+
+.nav-link {
+ color: var(--fg);
+ text-decoration: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ transition: background-color 0.3s ease;
+}
+
+.nav-link:hover {
+ background-color: var(--border);
+}
+
+.nav-link.active {
+ background-color: var(--primary);
+ color: white;
+}
+
+.composer {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.composer h2 {
+ color: var(--primary);
+ margin-bottom: 15px;
+}
+
+#task-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 15px;
+}
+
+#task-form input,
+#task-form textarea,
+#task-form select {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background-color: var(--bg);
+ color: var(--fg);
+ font-size: 1rem;
+}
+
+#task-form textarea {
+ grid-column: span 2;
+ resize: vertical;
+ min-height: 100px;
+}
+
+#task-form button {
+ grid-column: span 2;
+ padding: 12px 24px;
+ background-color: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
+}
+
+#task-form button:hover {
+ background-color: #2980b9;
+}
+
+#board {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.column {
+ background-color: var(--card-bg);
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+}
+
+.column-header {
+ padding: 15px;
+ background-color: var(--dark);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.column-header h3 {
+ color: var(--fg);
+}
+
+.column-count {
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.9rem;
+}
+
+.cards {
+ padding: 15px;
+ min-height: 200px;
+}
+
+.card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 15px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+}
+
+.card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.card-title {
+ color: var(--card-fg);
+ font-size: 1.1rem;
+}
+
+.badge {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: bold;
+}
+
+.badge.priority-Low {
+ background-color: var(--secondary);
+ color: white;
+}
+
+.badge.priority-Medium {
+ background-color: var(--warning);
+ color: white;
+}
+
+.badge.priority-High {
+ background-color: var(--danger);
+ color: white;
+}
+
+.badge.priority-Critical {
+ background-color: #9c27b0;
+ color: white;
+}
+
+.card-desc {
+ color: var(--fg);
+ margin-bottom: 10px;
+}
+
+.meta {
+ font-size: 0.9rem;
+ color: var(--border);
+ margin-bottom: 5px;
+}
+
+.meta.assignee {
+ font-weight: bold;
+}
+
+.tag {
+ display: inline-block;
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 2px 6px;
+ border-radius: 4px;
+ margin-right: 5px;
+ margin-bottom: 5px;
+}
+
+.card-check {
+ margin-right: 10px;
+}
+
+/* Wiki */
+#page-wiki {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.wiki-item {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 10px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+.wiki-item:hover {
+ background-color: var(--dark);
+}
+
+.wiki-item.active {
+ background-color: var(--primary);
+ color: white;
+}
+
+.wiki-title {
+ color: var(--card-fg);
+ margin-bottom: 5px;
+}
+
+.wiki-date {
+ color: var(--border);
+ font-size: 0.9rem;
+}
+
+#wiki-content {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+ margin-top: 20px;
+ white-space: pre-wrap;
+ color: var(--fg);
+}
+
+/* Agents */
+#page-agents {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.agent-card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 15px;
+}
+
+.agent-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.agent-name {
+ color: var(--card-fg);
+ font-size: 1.1rem;
+}
+
+.agent-status {
+ background-color: var(--secondary);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+.agent-section {
+ margin-bottom: 15px;
+}
+
+.agent-section h4 {
+ color: var(--primary);
+ margin-bottom: 8px;
+}
+
+.agent-task {
+ color: var(--fg);
+}
+
+.agent-tools,
+.agent-files {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.tool-tag,
+.file-tag {
+ background-color: var(--border);
+ color: var(--fg);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+}
+
+/* Usage */
+#page-usage {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 12px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.usage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.provider-card {
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 15px;
+}
+
+.provider-card h4 {
+ color: var(--card-fg);
+ margin-bottom: 10px;
+}
+
+.model-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.model-item {
+ display: flex;
+ justify-content: space-between;
+ background-color: var(--dark);
+ padding: 8px;
+ border-radius: 4px;
+}
+
+.model-name {
+ color: var(--primary);
+ font-weight: bold;
+}
+
+.model-type,
+.model-context {
+ color: var(--fg);
+ font-size: 0.9rem;
+}
+
+/* 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;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ #task-form {
+ grid-template-columns: 1fr;
+ }
+
+ #task-form textarea {
+ grid-column: span 1;
+ }
+
+ #task-form button {
+ grid-column: span 1;
+ }
+
+ .usage-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/server.js b/server.js
index 0d4e0de..acd9686 100644
--- a/server.js
+++ b/server.js
@@ -34,6 +34,20 @@ db.serialize(() => {
completed_at TEXT
)
`);
+
+ // Usage tracking table
+ db.run(`
+ CREATE TABLE IF NOT EXISTS usage_tracking (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ agent TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ request_type TEXT DEFAULT 'chat',
+ tokens_used INTEGER DEFAULT 0,
+ cost_estimate REAL DEFAULT 0,
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
});
const app = express();
@@ -112,6 +126,8 @@ function validatePayload(body, partial = false) {
return errors;
}
+// ============ TASKS API ============
+
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
if (err) {
@@ -238,6 +254,207 @@ app.patch('/api/tasks/:id', (req, res) => {
});
});
+// ============ WIKI API ============
+
+// Helper to extract frontmatter metadata from markdown
+function extractMetadata(content) {
+ const metadata = {
+ title: '',
+ created: null,
+ modified: null,
+ tags: []
+ };
+
+ // Check for YAML-like frontmatter
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
+ if (frontmatterMatch) {
+ const frontmatter = frontmatterMatch[1];
+ const titleMatch = frontmatter.match(/title:\s*(.+)/i);
+ const createdMatch = frontmatter.match(/created:\s*(.+)/i);
+ const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
+ const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
+
+ if (titleMatch) metadata.title = titleMatch[1].trim();
+ if (createdMatch) metadata.created = createdMatch[1].trim();
+ if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
+ if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
+ }
+
+ // Extract title from first heading if not in frontmatter
+ if (!metadata.title) {
+ const headingMatch = content.match(/^#\s+(.+)$/m);
+ if (headingMatch) {
+ metadata.title = headingMatch[1].trim();
+ }
+ }
+
+ return metadata;
+}
+
+// GET /api/wiki - List all wiki pages
+app.get('/api/wiki', (req, res) => {
+ try {
+ if (!fs.existsSync(WIKI_DIR)) {
+ fs.mkdirSync(WIKI_DIR, { recursive: true });
+ return res.json([]);
+ }
+
+ const files = fs.readdirSync(WIKI_DIR)
+ .filter(f => f.endsWith('.md'))
+ .map(filename => {
+ const filePath = path.join(WIKI_DIR, filename);
+ const stats = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath, 'utf8');
+ const metadata = extractMetadata(content);
+
+ return {
+ filename,
+ title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
+ created: stats.birthtime.toISOString(),
+ modified: stats.mtime.toISOString(),
+ tags: metadata.tags
+ };
+ })
+ .sort((a, b) => new Date(b.modified) - new Date(a.modified));
+
+ res.json(files);
+ } catch (err) {
+ console.error('Error listing wiki pages:', err);
+ res.status(500).json({ error: 'failed_to_list_wiki_pages' });
+ }
+});
+
+// GET /api/wiki/:filename - Get specific wiki page content
+app.get('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_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');
+ const stats = fs.statSync(filePath);
+ const metadata = extractMetadata(content);
+
+ res.json({
+ filename,
+ content,
+ metadata: {
+ ...metadata,
+ created: stats.birthtime.toISOString(),
+ modified: stats.mtime.toISOString()
+ }
+ });
+ } catch (err) {
+ console.error('Error reading wiki page:', err);
+ res.status(500).json({ error: 'failed_to_read_wiki_page' });
+ }
+});
+
+// POST /api/wiki - Create new wiki page
+app.post('/api/wiki', (req, res) => {
+ try {
+ const { title, content } = req.body;
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return res.status(400).json({ error: 'title_is_required' });
+ }
+
+ const safeTitle = title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+
+ const timestamp = new Date().toISOString().slice(0, 10);
+ let filename = `${timestamp}-${safeTitle}.md`;
+
+ // Ensure unique filename
+ let counter = 1;
+ while (fs.existsSync(path.join(WIKI_DIR, filename))) {
+ filename = `${timestamp}-${safeTitle}-${counter}.md`;
+ counter++;
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+ const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
+
+ fs.writeFileSync(filePath, pageContent, 'utf8');
+
+ broadcast('wiki_created', { filename, title });
+ res.status(201).json({ filename, success: true, title });
+ } catch (err) {
+ console.error('Error creating wiki page:', err);
+ res.status(500).json({ error: 'failed_to_create_wiki_page' });
+ }
+});
+
+// PUT /api/wiki/:filename - Update wiki page
+app.put('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+ const { content } = req.body;
+
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_filename' });
+ }
+
+ if (typeof content !== 'string') {
+ return res.status(400).json({ error: 'content_is_required' });
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ error: 'wiki_page_not_found' });
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+
+ broadcast('wiki_updated', { filename });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Error updating wiki page:', err);
+ res.status(500).json({ error: 'failed_to_update_wiki_page' });
+ }
+});
+
+// DELETE /api/wiki/:filename - Delete wiki page
+app.delete('/api/wiki/:filename', (req, res) => {
+ try {
+ const filename = req.params.filename;
+
+ // Security: prevent path traversal
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+ return res.status(400).json({ error: 'invalid_filename' });
+ }
+
+ const filePath = path.join(WIKI_DIR, filename);
+
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ error: 'wiki_page_not_found' });
+ }
+
+ fs.unlinkSync(filePath);
+
+ broadcast('wiki_deleted', { filename });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Error deleting wiki page:', err);
+ res.status(500).json({ error: 'failed_to_delete_wiki_page' });
+ }
+});
+
+// ============ AGENTS API (Enhanced) ============
+
app.get('/api/agents', (req, res) => {
try {
const agents = [];
@@ -247,7 +464,47 @@ app.get('/api/agents', (req, res) => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
- agentDirs.forEach(agentName => {
+ // Get task counts per agent (workload) and completed tasks (history)
+ const getAgentTaskData = (agentName) => {
+ return new Promise((resolve) => {
+ const result = {
+ workload: 0,
+ activeTasks: [],
+ completedTasks: []
+ };
+
+ // Get workload (tasks in Todo, In Progress, Review)
+ db.all(
+ `SELECT * FROM tasks
+ WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
+ ORDER BY priority DESC, created_at ASC`,
+ [agentName],
+ (err, activeRows) => {
+ if (!err && activeRows) {
+ result.workload = activeRows.length;
+ result.activeTasks = activeRows.map(normalizeTask);
+ }
+
+ // Get last 5 completed tasks
+ db.all(
+ `SELECT * FROM tasks
+ WHERE assignee = ? AND status = 'Done'
+ ORDER BY completed_at DESC
+ LIMIT 5`,
+ [agentName],
+ (err2, completedRows) => {
+ if (!err2 && completedRows) {
+ result.completedTasks = completedRows.map(normalizeTask);
+ }
+ resolve(result);
+ }
+ );
+ }
+ );
+ });
+ };
+
+ const agentPromises = agentDirs.map(async (agentName) => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
@@ -257,7 +514,11 @@ app.get('/api/agents', (req, res) => {
currentTask: null,
tools: [],
files: [],
- permissions: []
+ permissions: [],
+ workload: 0,
+ activeTasks: [],
+ completedTasks: [],
+ capabilities: []
};
if (fs.existsSync(workspacePath)) {
@@ -267,36 +528,104 @@ app.get('/api/agents', (req, res) => {
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);
+ const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
if (toolMatches) {
- agent.tools = toolMatches[1].split('\\n')
+ agent.tools = toolMatches[1].split('\n')
.filter(line => line.trim().startsWith('-'))
- .map(line => line.replace(/^-\\s*/, '').trim());
+ .map(line => line.replace(/^-\s*/, '').trim());
+ }
+
+ // Extract capabilities/skills
+ const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
+ if (skillsMatch) {
+ agent.capabilities = skillsMatch[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);
+ const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
+
+ // Check last heartbeat time for status
+ const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
+ if (timeMatch) {
+ const lastBeat = new Date(timeMatch[1]);
+ const now = new Date();
+ const minutesAgo = (now - lastBeat) / 1000 / 60;
+
+ if (minutesAgo > 30) {
+ agent.status = 'idle';
+ } else if (minutesAgo > 10) {
+ agent.status = 'busy';
+ }
+ }
}
}
+
+ // Get task data from database
+ const taskData = await getAgentTaskData(agentName);
+ agent.workload = taskData.workload;
+ agent.activeTasks = taskData.activeTasks;
+ agent.completedTasks = taskData.completedTasks;
- agents.push(agent);
+ return agent;
});
- }
- res.json(agents);
+ Promise.all(agentPromises).then(results => {
+ res.json(results);
+ });
+ } else {
+ res.json([]);
+ }
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
-// Usage endpoint
+// POST /api/agents/:name/assign - Assign task to agent
+app.post('/api/agents/:name/assign', (req, res) => {
+ const agentName = req.params.name;
+ const { taskId } = req.body;
+
+ if (!taskId) {
+ return res.status(400).json({ error: 'taskId_is_required' });
+ }
+
+ db.run(
+ 'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
+ [agentName, taskId],
+ function(err) {
+ if (err) {
+ return res.status(500).json({ error: 'failed_to_assign_task' });
+ }
+
+ if (this.changes === 0) {
+ return res.status(404).json({ error: 'task_not_found' });
+ }
+
+ db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
+ if (fetchErr || !row) {
+ return res.status(500).json({ error: 'failed_to_fetch_task' });
+ }
+
+ const task = normalizeTask(row);
+ broadcast('task_assigned', { agent: agentName, task });
+ res.json({ success: true, task });
+ });
+ }
+ );
+});
+
+// ============ USAGE API (Enhanced) ============
+
+// GET /api/usage - Basic usage info (existing)
app.get('/api/usage', (req, res) => {
try {
const usage = {
@@ -343,7 +672,191 @@ app.get('/api/usage', (req, res) => {
}
});
-// Heartbeat endpoint for agents
+// GET /api/usage/stats - Usage statistics with date range
+app.get('/api/usage/stats', (req, res) => {
+ const { from, to } = req.query;
+
+ let query = 'SELECT * FROM usage_tracking';
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' ORDER BY timestamp DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error fetching usage stats:', err);
+ return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
+ }
+
+ // Aggregate stats
+ const stats = {
+ totalRequests: rows.length,
+ totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
+ totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
+ byProvider: {},
+ byAgent: {},
+ byModel: {},
+ records: rows
+ };
+
+ rows.forEach(record => {
+ // By provider
+ if (!stats.byProvider[record.provider]) {
+ stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byProvider[record.provider].requests++;
+ stats.byProvider[record.provider].tokens += record.tokens_used || 0;
+ stats.byProvider[record.provider].cost += record.cost_estimate || 0;
+
+ // By agent
+ if (!stats.byAgent[record.agent]) {
+ stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byAgent[record.agent].requests++;
+ stats.byAgent[record.agent].tokens += record.tokens_used || 0;
+ stats.byAgent[record.agent].cost += record.cost_estimate || 0;
+
+ // By model
+ if (!stats.byModel[record.model]) {
+ stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
+ }
+ stats.byModel[record.model].requests++;
+ stats.byModel[record.model].tokens += record.tokens_used || 0;
+ stats.byModel[record.model].cost += record.cost_estimate || 0;
+ });
+
+ res.json(stats);
+ });
+});
+
+// GET /api/usage/agents - Usage breakdown by agent
+app.get('/api/usage/agents', (req, res) => {
+ const { from, to } = req.query;
+
+ let query = `
+ SELECT agent,
+ COUNT(*) as requests,
+ SUM(tokens_used) as tokens,
+ SUM(cost_estimate) as cost,
+ provider,
+ model
+ FROM usage_tracking
+ `;
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' GROUP BY agent ORDER BY requests DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error fetching agent usage:', err);
+ return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
+ }
+
+ res.json(rows);
+ });
+});
+
+// POST /api/usage/track - Track usage (for external callers)
+app.post('/api/usage/track', (req, res) => {
+ const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
+
+ if (!agent || !provider || !model) {
+ return res.status(400).json({ error: 'agent, provider, and model are required' });
+ }
+
+ db.run(
+ `INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
+ function(err) {
+ if (err) {
+ console.error('Error tracking usage:', err);
+ return res.status(500).json({ error: 'failed_to_track_usage' });
+ }
+
+ res.status(201).json({ success: true, id: this.lastID });
+ }
+ );
+});
+
+// GET /api/usage/export - Export usage data
+app.get('/api/usage/export', (req, res) => {
+ const { format = 'json', from, to } = req.query;
+
+ let query = 'SELECT * FROM usage_tracking';
+ const params = [];
+ const conditions = [];
+
+ if (from) {
+ conditions.push('timestamp >= ?');
+ params.push(from);
+ }
+
+ if (to) {
+ conditions.push('timestamp <= ?');
+ params.push(to);
+ }
+
+ if (conditions.length > 0) {
+ query += ' WHERE ' + conditions.join(' AND ');
+ }
+
+ query += ' ORDER BY timestamp DESC';
+
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ console.error('Error exporting usage:', err);
+ return res.status(500).json({ error: 'failed_to_export_usage' });
+ }
+
+ if (format === 'csv') {
+ const csv = [
+ 'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
+ ...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
+ ].join('\n');
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
+ res.send(csv);
+ } else {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
+ res.json(rows);
+ }
+ });
+});
+
+// ============ HEARTBEAT ============
+
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
@@ -365,6 +878,8 @@ app.get('/api/heartbeat/:agent', (req, res) => {
);
});
+// ============ WEBSOCKET ============
+
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});
diff --git a/server.js.backup b/server.js.backup
new file mode 100644
index 0000000..0d4e0de
--- /dev/null
+++ b/server.js.backup
@@ -0,0 +1,374 @@
+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);
+ }
+ }
+
+ broadcast('task_updated', task);
+ return res.json(task);
+ });
+ });
+ });
+ });
+
+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}`);
+});