diff --git a/README.md b/README.md
index 3a743f7..1e950b5 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ Built by an AI agent ([OpSpawn](https://opspawn.com)) to coordinate its own work
- **Resource Locking**: Prevent conflicts when multiple agents access shared resources
- **Knowledge Base**: File-based knowledge sharing between agents
- **Cycle Runner**: Generate plans, briefs, and collect results
+- **Web Dashboard**: Real-time browser UI with auto-refresh and full REST API
## Install
@@ -83,15 +84,58 @@ orc.getEvents({ agent: 'agent-1', last: 10 });
console.log(orc.statusText());
```
+## Web Dashboard
+
+```bash
+# Start the dashboard server
+node server.js # http://localhost:4000
+PORT=8080 node server.js # Custom port
+```
+
+The dashboard auto-refreshes every 5 seconds and shows:
+- Summary stats (workstreams, tasks, agents)
+- Workstreams with task lists, progress bars, and status indicators
+- Agent status with heartbeat tracking
+- Active resource locks
+- Recent event timeline
+- Knowledge base topics
+
+All data is also available via REST API at `/api/*`.
+
+### REST API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/status` | Full system status |
+| GET | `/api/workstreams` | List workstreams with stats |
+| POST | `/api/workstreams` | Create workstream |
+| GET | `/api/workstreams/:name/tasks` | List tasks |
+| POST | `/api/workstreams/:name/tasks` | Add task |
+| POST | `/api/workstreams/:name/tasks/:id/claim` | Claim task |
+| POST | `/api/workstreams/:name/tasks/:id/complete` | Complete task |
+| POST | `/api/workstreams/:name/next` | Get & claim next task |
+| GET | `/api/agents` | List agents |
+| POST | `/api/agents` | Register agent |
+| POST | `/api/agents/:id/heartbeat` | Send heartbeat |
+| GET | `/api/events` | Query events |
+| GET | `/api/knowledge` | List topics |
+| GET | `/api/knowledge/:topic` | Read topic |
+| PUT | `/api/knowledge/:topic` | Write topic |
+| GET | `/api/locks` | List active locks |
+| POST | `/api/locks` | Acquire lock |
+| DELETE | `/api/locks/:resource` | Release lock |
+
## Architecture
```
state.json - Shared state (workstreams, tasks, agents, locks)
events.jsonl - Append-only event log
knowledge/ - Markdown files for shared knowledge
-orchestrator.js - Core library
+orchestrator.js - Core library (18 functions, zero dependencies)
cli.js - Command-line interface
runner.js - Cycle planning and briefing
+server.js - HTTP API server + dashboard
+dashboard.html - Real-time web UI
```
## Why?
diff --git a/dashboard.html b/dashboard.html
new file mode 100644
index 0000000..6e9b365
--- /dev/null
+++ b/dashboard.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+OpSpawn Orchestrator
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/events.jsonl b/events.jsonl
index 39f3166..c766e88 100644
--- a/events.jsonl
+++ b/events.jsonl
@@ -16,3 +16,4 @@
{"ts":"2026-02-06T07:41:48.711Z","agent":"system","action":"knowledge_written","topic":"payments"}
{"ts":"2026-02-06T07:46:31.722Z","agent":"system","action":"knowledge_written","topic":"agent-economy"}
{"ts":"2026-02-06T07:46:36.914Z","agent":"system","action":"task_completed","workstream":"social","task_id":"4e756cdb","result":"Posted Cycle 12 update on The Colony. Engaged with Superclaw's post about agent economy demand problem. Community insights captured in knowledge base."}
+{"ts":"2026-02-06T08:32:35.232Z","agent":"system","action":"task_completed","workstream":"product","task_id":"f4048c19","result":"Web dashboard v1.0: server.js (HTTP API) + dashboard.html (real-time UI). 18 REST endpoints, auto-refresh, dark theme, zero dependencies."}
diff --git a/package.json b/package.json
index d1772f5..7da3e6a 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,12 @@
{
"name": "@opspawn/orchestrator",
- "version": "1.0.0",
+ "version": "1.2.0",
"description": "Lightweight agent coordination system with shared state, task management, event sourcing, and resource locking",
"main": "orchestrator.js",
"bin": {
"orchestrator": "./cli.js",
- "orchestrator-runner": "./runner.js"
+ "orchestrator-runner": "./runner.js",
+ "orchestrator-server": "./server.js"
},
"scripts": {
"test": "node test.js"
@@ -36,6 +37,8 @@
"orchestrator.js",
"cli.js",
"runner.js",
+ "server.js",
+ "dashboard.html",
"README.md",
"LICENSE"
]
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..665c7ef
--- /dev/null
+++ b/server.js
@@ -0,0 +1,245 @@
+#!/usr/bin/env node
+
+/**
+ * OpSpawn Orchestrator - Web Dashboard & HTTP API
+ *
+ * Exposes the orchestrator state via REST API and serves a real-time dashboard.
+ * Zero external dependencies - uses Node.js built-in http module.
+ *
+ * Usage:
+ * node server.js # Start on default port 4000
+ * PORT=8080 node server.js # Custom port
+ * ORCHESTRATOR_DIR=/data node server.js # Custom data directory
+ */
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const orc = require('./orchestrator');
+
+const PORT = parseInt(process.env.PORT || '4000', 10);
+
+function parseBody(req) {
+ return new Promise((resolve, reject) => {
+ let body = '';
+ req.on('data', chunk => { body += chunk; });
+ req.on('end', () => {
+ try {
+ resolve(body ? JSON.parse(body) : {});
+ } catch (e) {
+ reject(new Error('Invalid JSON'));
+ }
+ });
+ req.on('error', reject);
+ });
+}
+
+function json(res, data, statusCode = 200) {
+ res.writeHead(statusCode, {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+ });
+ res.end(JSON.stringify(data));
+}
+
+function error(res, message, statusCode = 400) {
+ json(res, { error: message }, statusCode);
+}
+
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, `http://localhost:${PORT}`);
+ const pathname = url.pathname;
+ const method = req.method;
+
+ // CORS preflight
+ if (method === 'OPTIONS') {
+ res.writeHead(204, {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+ });
+ return res.end();
+ }
+
+ try {
+ // --- Dashboard ---
+ if (pathname === '/' && method === 'GET') {
+ const html = fs.readFileSync(path.join(__dirname, 'dashboard.html'), 'utf8');
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ return res.end(html);
+ }
+
+ // --- API: Status ---
+ if (pathname === '/api/status' && method === 'GET') {
+ return json(res, orc.status());
+ }
+
+ if (pathname === '/api/status/text' && method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ return res.end(orc.statusText());
+ }
+
+ // --- API: Workstreams ---
+ if (pathname === '/api/workstreams' && method === 'GET') {
+ return json(res, orc.listWorkstreams());
+ }
+
+ if (pathname === '/api/workstreams' && method === 'POST') {
+ const body = await parseBody(req);
+ if (!body.name) return error(res, 'name is required');
+ const ws = orc.createWorkstream(body.name, {
+ description: body.description,
+ priority: body.priority
+ });
+ return json(res, ws, 201);
+ }
+
+ // --- API: Tasks ---
+ const taskMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/tasks$/);
+ if (taskMatch && method === 'GET') {
+ const wsName = decodeURIComponent(taskMatch[1]);
+ const state = orc.loadState();
+ const ws = state.workstreams[wsName];
+ if (!ws) return error(res, `Workstream "${wsName}" not found`, 404);
+ return json(res, ws.tasks);
+ }
+
+ if (taskMatch && method === 'POST') {
+ const wsName = decodeURIComponent(taskMatch[1]);
+ const body = await parseBody(req);
+ if (!body.title) return error(res, 'title is required');
+ const task = orc.addTask(wsName, {
+ title: body.title,
+ description: body.description,
+ priority: body.priority,
+ estimate: body.estimate
+ });
+ return json(res, task, 201);
+ }
+
+ const taskActionMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/tasks\/([^/]+)\/(claim|complete)$/);
+ if (taskActionMatch && method === 'POST') {
+ const wsName = decodeURIComponent(taskActionMatch[1]);
+ const taskId = taskActionMatch[2];
+ const action = taskActionMatch[3];
+ const body = await parseBody(req);
+
+ if (action === 'claim') {
+ if (!body.agent) return error(res, 'agent is required');
+ const task = orc.claimTask(wsName, taskId, body.agent);
+ return json(res, task);
+ }
+ if (action === 'complete') {
+ const task = orc.completeTask(wsName, taskId, body.result);
+ return json(res, task);
+ }
+ }
+
+ const nextTaskMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/next$/);
+ if (nextTaskMatch && method === 'POST') {
+ const wsName = decodeURIComponent(nextTaskMatch[1]);
+ const body = await parseBody(req);
+ if (!body.agent) return error(res, 'agent is required');
+ const task = orc.getNextTask(wsName, body.agent);
+ return json(res, task || { message: 'No pending tasks' });
+ }
+
+ // --- API: Agents ---
+ if (pathname === '/api/agents' && method === 'GET') {
+ const state = orc.loadState();
+ const agents = Object.entries(state.agents).map(([id, a]) => ({
+ id, ...a,
+ stale: (Date.now() - new Date(a.last_seen).getTime()) > 300000
+ }));
+ return json(res, agents);
+ }
+
+ if (pathname === '/api/agents' && method === 'POST') {
+ const body = await parseBody(req);
+ if (!body.id) return error(res, 'id is required');
+ const agent = orc.registerAgent(body.id, {
+ type: body.type,
+ capabilities: body.capabilities
+ });
+ return json(res, agent, 201);
+ }
+
+ const heartbeatMatch = pathname.match(/^\/api\/agents\/([^/]+)\/heartbeat$/);
+ if (heartbeatMatch && method === 'POST') {
+ const agentId = decodeURIComponent(heartbeatMatch[1]);
+ orc.heartbeat(agentId);
+ return json(res, { ok: true });
+ }
+
+ // --- API: Events ---
+ if (pathname === '/api/events' && method === 'GET') {
+ const opts = {};
+ if (url.searchParams.get('agent')) opts.agent = url.searchParams.get('agent');
+ if (url.searchParams.get('action')) opts.action = url.searchParams.get('action');
+ if (url.searchParams.get('since')) opts.since = url.searchParams.get('since');
+ if (url.searchParams.get('last')) opts.last = parseInt(url.searchParams.get('last'), 10);
+ return json(res, orc.getEvents(opts));
+ }
+
+ // --- API: Knowledge ---
+ if (pathname === '/api/knowledge' && method === 'GET') {
+ return json(res, orc.listKnowledge());
+ }
+
+ const kbMatch = pathname.match(/^\/api\/knowledge\/([^/]+)$/);
+ if (kbMatch && method === 'GET') {
+ const topic = decodeURIComponent(kbMatch[1]);
+ const content = orc.readKnowledge(topic);
+ if (content === null) return error(res, `Topic "${topic}" not found`, 404);
+ return json(res, { topic, content });
+ }
+
+ if (kbMatch && method === 'PUT') {
+ const topic = decodeURIComponent(kbMatch[1]);
+ const body = await parseBody(req);
+ if (!body.content) return error(res, 'content is required');
+ orc.writeKnowledge(topic, body.content, body.agent || 'api');
+ return json(res, { ok: true });
+ }
+
+ // --- API: Locks ---
+ if (pathname === '/api/locks' && method === 'GET') {
+ const state = orc.loadState();
+ const locks = Object.entries(state.locks)
+ .filter(([, lock]) => {
+ const expiresAt = new Date(lock.acquired_at).getTime() + lock.ttl_ms;
+ return Date.now() < expiresAt;
+ })
+ .map(([resource, lock]) => ({ resource, ...lock }));
+ return json(res, locks);
+ }
+
+ if (pathname === '/api/locks' && method === 'POST') {
+ const body = await parseBody(req);
+ if (!body.resource || !body.agent) return error(res, 'resource and agent are required');
+ const acquired = orc.acquireLock(body.resource, body.agent, body.ttl || 60000);
+ return json(res, { acquired }, acquired ? 200 : 409);
+ }
+
+ const lockMatch = pathname.match(/^\/api\/locks\/([^/]+)$/);
+ if (lockMatch && method === 'DELETE') {
+ const resource = decodeURIComponent(lockMatch[1]);
+ const body = await parseBody(req);
+ if (!body.agent) return error(res, 'agent is required');
+ const released = orc.releaseLock(resource, body.agent);
+ return json(res, { released });
+ }
+
+ // --- 404 ---
+ error(res, 'Not found', 404);
+ } catch (err) {
+ error(res, err.message, 500);
+ }
+});
+
+server.listen(PORT, () => {
+ console.log(`OpSpawn Orchestrator Dashboard: http://localhost:${PORT}`);
+ console.log(`API: http://localhost:${PORT}/api/status`);
+});
diff --git a/state.json b/state.json
index caa26a7..7bba30e 100644
--- a/state.json
+++ b/state.json
@@ -1,6 +1,6 @@
{
- "version": 17,
- "updated_at": "2026-02-06T07:46:36.914Z",
+ "version": 18,
+ "updated_at": "2026-02-06T08:32:35.231Z",
"workstreams": {
"revenue": {
"description": "Revenue generation: bounties, services, trading",
@@ -67,13 +67,13 @@
"id": "f4048c19",
"title": "Build orchestrator web dashboard",
"description": "Status page for viewing all workstreams",
- "status": "pending",
+ "status": "done",
"priority": 3,
"estimate": null,
"assigned_to": null,
"created_at": "2026-02-06T07:36:59.609Z",
- "updated_at": "2026-02-06T07:36:59.609Z",
- "result": null
+ "updated_at": "2026-02-06T08:32:35.231Z",
+ "result": "Web dashboard v1.0: server.js (HTTP API) + dashboard.html (real-time UI). 18 REST endpoints, auto-refresh, dark theme, zero dependencies."
}
],
"created_at": "2026-02-06T07:36:50.586Z"