neohive 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +640 -0
- package/LICENSE +75 -0
- package/README.md +342 -0
- package/SECURITY.md +58 -0
- package/cli.js +931 -0
- package/conversation-templates/autonomous-feature.json +22 -0
- package/conversation-templates/code-review.json +21 -0
- package/conversation-templates/debug-squad.json +21 -0
- package/conversation-templates/feature-build.json +21 -0
- package/conversation-templates/research-write.json +21 -0
- package/dashboard.html +8571 -0
- package/dashboard.js +2962 -0
- package/lib/agents.js +107 -0
- package/lib/compact.js +124 -0
- package/lib/config.js +127 -0
- package/lib/file-io.js +166 -0
- package/lib/logger.js +13 -0
- package/lib/messaging.js +137 -0
- package/lib/state.js +23 -0
- package/logo.png +0 -0
- package/package.json +57 -0
- package/server.js +7179 -0
- package/templates/debate.json +16 -0
- package/templates/managed.json +26 -0
- package/templates/pair.json +16 -0
- package/templates/review.json +16 -0
- package/templates/team.json +21 -0
package/dashboard.js
ADDED
|
@@ -0,0 +1,2962 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
// --- File-level mutex for serializing read-then-write operations ---
|
|
9
|
+
const lockMap = new Map();
|
|
10
|
+
function withFileLock(filePath, fn) {
|
|
11
|
+
const prev = lockMap.get(filePath) || Promise.resolve();
|
|
12
|
+
const next = prev.then(fn, fn);
|
|
13
|
+
lockMap.set(filePath, next.then(() => {}, () => {}));
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PORT = parseInt(process.env.NEOHIVE_PORT || '3000', 10);
|
|
18
|
+
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
19
|
+
let LAN_MODE = process.env.NEOHIVE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
20
|
+
|
|
21
|
+
const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
|
|
22
|
+
let LAN_TOKEN = null;
|
|
23
|
+
|
|
24
|
+
function generateLanToken() {
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
LAN_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
27
|
+
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN, { mode: 0o600 }); } catch {}
|
|
28
|
+
return LAN_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadLanToken() {
|
|
32
|
+
if (fs.existsSync(LAN_TOKEN_FILE)) {
|
|
33
|
+
try { LAN_TOKEN = fs.readFileSync(LAN_TOKEN_FILE, 'utf8').trim(); } catch {}
|
|
34
|
+
}
|
|
35
|
+
if (!LAN_TOKEN) generateLanToken();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load or generate token on startup
|
|
39
|
+
loadLanToken();
|
|
40
|
+
|
|
41
|
+
function persistLanMode() {
|
|
42
|
+
try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getLanIP() {
|
|
46
|
+
const interfaces = os.networkInterfaces();
|
|
47
|
+
let fallback = null;
|
|
48
|
+
for (const name of Object.keys(interfaces)) {
|
|
49
|
+
for (const iface of interfaces[name]) {
|
|
50
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
51
|
+
// Prefer real LAN IPs (192.168.x, 10.x, 172.16-31.x) over link-local (169.254.x)
|
|
52
|
+
if (!iface.address.startsWith('169.254.')) return iface.address;
|
|
53
|
+
if (!fallback) fallback = iface.address;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
const DEFAULT_DATA_DIR = process.env.NEOHIVE_DATA || path.join(process.cwd(), '.neohive');
|
|
60
|
+
const HTML_FILE = path.join(__dirname, 'dashboard.html');
|
|
61
|
+
const LOGO_FILE = path.join(__dirname, 'logo.png');
|
|
62
|
+
const PROJECTS_FILE = path.join(__dirname, 'projects.json');
|
|
63
|
+
|
|
64
|
+
// --- Multi-project support ---
|
|
65
|
+
|
|
66
|
+
function getProjects() {
|
|
67
|
+
if (!fs.existsSync(PROJECTS_FILE)) return [];
|
|
68
|
+
try { return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); } catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveProjects(projects) {
|
|
72
|
+
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if a directory has actual data files (not just an empty dir)
|
|
76
|
+
function hasDataFiles(dir) {
|
|
77
|
+
if (!fs.existsSync(dir)) return false;
|
|
78
|
+
try {
|
|
79
|
+
const files = fs.readdirSync(dir);
|
|
80
|
+
return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
|
|
81
|
+
} catch { return false; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve data dir: explicit project path > env var > cwd > legacy fallback
|
|
85
|
+
// Prefers directories with actual data files over empty ones
|
|
86
|
+
function resolveDataDir(projectPath) {
|
|
87
|
+
if (projectPath) {
|
|
88
|
+
const dir = path.join(projectPath, '.neohive');
|
|
89
|
+
const dataDir = path.join(projectPath, 'data');
|
|
90
|
+
// Prefer whichever has data
|
|
91
|
+
if (hasDataFiles(dir)) return dir;
|
|
92
|
+
if (hasDataFiles(dataDir)) return dataDir;
|
|
93
|
+
if (fs.existsSync(dir)) return dir;
|
|
94
|
+
if (fs.existsSync(dataDir)) return dataDir;
|
|
95
|
+
return dir;
|
|
96
|
+
}
|
|
97
|
+
const legacyDir = path.join(__dirname, 'data');
|
|
98
|
+
// Prefer dir with actual data files
|
|
99
|
+
if (hasDataFiles(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
100
|
+
if (hasDataFiles(legacyDir)) return legacyDir;
|
|
101
|
+
if (fs.existsSync(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
102
|
+
if (fs.existsSync(legacyDir)) return legacyDir;
|
|
103
|
+
return DEFAULT_DATA_DIR;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function filePath(name, projectPath) {
|
|
107
|
+
return path.join(resolveDataDir(projectPath), name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Validate project path is registered or is the default
|
|
111
|
+
function validateProjectPath(projectPath) {
|
|
112
|
+
if (!projectPath) return true;
|
|
113
|
+
const absPath = path.resolve(projectPath);
|
|
114
|
+
const projects = getProjects();
|
|
115
|
+
const cwd = path.resolve(process.cwd());
|
|
116
|
+
const scriptDir = path.resolve(__dirname);
|
|
117
|
+
if (absPath === cwd || absPath === scriptDir) return true;
|
|
118
|
+
return projects.some(p => path.resolve(p.path) === absPath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function htmlEscape(s) {
|
|
122
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Shared helpers ---
|
|
126
|
+
|
|
127
|
+
function readJsonl(file) {
|
|
128
|
+
if (!fs.existsSync(file)) return [];
|
|
129
|
+
const content = fs.readFileSync(file, 'utf8').trim();
|
|
130
|
+
if (!content) return [];
|
|
131
|
+
return content.split(/\r?\n/).map(line => {
|
|
132
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
133
|
+
}).filter(Boolean);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(file) {
|
|
137
|
+
if (!fs.existsSync(file)) return {};
|
|
138
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPidAlive(pid, lastActivity) {
|
|
142
|
+
const STALE_THRESHOLD = 60000; // 60s — if heartbeat updated within this, agent is alive
|
|
143
|
+
|
|
144
|
+
// PRIORITY 1: Trust heartbeat freshness over PID status
|
|
145
|
+
// Heartbeats are written by the actual running process — if fresh, agent is alive
|
|
146
|
+
// regardless of whether process.kill can see the PID
|
|
147
|
+
if (lastActivity) {
|
|
148
|
+
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
149
|
+
if (stale < STALE_THRESHOLD) return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// PRIORITY 2: If heartbeat is stale, check PID as fallback
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0);
|
|
155
|
+
return true; // PID exists — alive even with stale heartbeat
|
|
156
|
+
} catch {
|
|
157
|
+
return false; // PID dead AND heartbeat stale — truly dead
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Default avatar helpers ---
|
|
162
|
+
const BUILT_IN_AVATARS = [
|
|
163
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2358a6ff'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Crect x='20' y='38' width='24' height='4' rx='2' fill='%23fff'/%3E%3Crect x='14' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3Crect x='44' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3C/svg%3E",
|
|
164
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%233fb950'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 46 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
165
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23d29922'/%3E%3Crect x='16' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Crect x='36' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M24 40 H40' stroke='%23fff' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
166
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f85149'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M22 40 Q32 34 42 40' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
167
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23bc8cff'/%3E%3Ccircle cx='22' cy='28' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='28' r='4' fill='%23fff'/%3E%3Cpath d='M16 18 L22 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M48 18 L42 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cellipse cx='32' cy='42' rx='8' ry='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
168
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f778ba'/%3E%3Ccircle cx='24' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='40' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='24' cy='26' r='3' fill='%23333'/%3E%3Ccircle cx='40' cy='26' r='3' fill='%23333'/%3E%3Cpath d='M26 40 Q32 46 38 40' stroke='%23fff' fill='none' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
169
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2379c0ff'/%3E%3Crect x='17' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Crect x='37' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Cpath d='M22 38 L32 44 L42 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E",
|
|
170
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%237ee787'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%23333'/%3E%3Ccircle cx='43' cy='25' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 48 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
171
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23e3b341'/%3E%3Cpath d='M18 22 L26 30 L18 30Z' fill='%23fff'/%3E%3Cpath d='M46 22 L38 30 L46 30Z' fill='%23fff'/%3E%3Crect x='24' y='38' width='16' height='6' rx='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
172
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23ffa198'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='27' r='2.5' fill='%23333'/%3E%3Ccircle cx='42' cy='27' r='2.5' fill='%23333'/%3E%3Cellipse cx='32' cy='42' rx='6' ry='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
173
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%230969da'/%3E%3Crect x='16' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Crect x='34' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%230969da'/%3E%3Ccircle cx='41' cy='25' r='2' fill='%230969da'/%3E%3Crect x='26' y='38' width='12' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E",
|
|
174
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%238250df'/%3E%3Ccircle cx='24' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='40' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='24' cy='24' r='2' fill='%238250df'/%3E%3Ccircle cx='40' cy='24' r='2' fill='%238250df'/%3E%3Cpath d='M20 38 Q32 50 44 38' stroke='%23fff' fill='none' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='32' cy='10' r='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
function hashName(name) {
|
|
178
|
+
let h = 0;
|
|
179
|
+
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
|
180
|
+
return Math.abs(h);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getDefaultAvatar(name) {
|
|
184
|
+
return BUILT_IN_AVATARS[hashName(name) % BUILT_IN_AVATARS.length];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- API handlers ---
|
|
188
|
+
|
|
189
|
+
function apiHistory(query) {
|
|
190
|
+
const projectPath = query.get('project') || null;
|
|
191
|
+
const branch = query.get('branch') || null;
|
|
192
|
+
if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) {
|
|
193
|
+
return { error: 'Invalid branch name' };
|
|
194
|
+
}
|
|
195
|
+
const histFile = branch && branch !== 'main'
|
|
196
|
+
? filePath(`branch-${branch}-history.jsonl`, projectPath)
|
|
197
|
+
: filePath('history.jsonl', projectPath);
|
|
198
|
+
let history = readJsonl(histFile);
|
|
199
|
+
|
|
200
|
+
// Merge channel-specific history files
|
|
201
|
+
const dataDir = resolveDataDir(projectPath);
|
|
202
|
+
try {
|
|
203
|
+
const files = fs.readdirSync(dataDir);
|
|
204
|
+
for (const f of files) {
|
|
205
|
+
if (f.startsWith('channel-') && f.endsWith('-history.jsonl') && f !== 'channel-general-history.jsonl') {
|
|
206
|
+
const channelHistory = readJsonl(path.join(dataDir, f));
|
|
207
|
+
history = history.concat(channelHistory);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {}
|
|
211
|
+
// Sort merged messages by timestamp
|
|
212
|
+
history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
213
|
+
|
|
214
|
+
const acks = readJson(filePath('acks.json', projectPath));
|
|
215
|
+
const limit = Math.min(parseInt(query.get('limit') || '500', 10), 1000);
|
|
216
|
+
const page = parseInt(query.get('page') || '0', 10);
|
|
217
|
+
const threadId = query.get('thread_id');
|
|
218
|
+
|
|
219
|
+
let messages = history;
|
|
220
|
+
if (threadId) {
|
|
221
|
+
messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Scale fix: pagination support for large histories
|
|
225
|
+
const total = messages.length;
|
|
226
|
+
if (page > 0) {
|
|
227
|
+
// Page-based: page 1 = most recent, page 2 = older, etc.
|
|
228
|
+
const start = Math.max(0, total - (page * limit));
|
|
229
|
+
const end = Math.max(0, total - ((page - 1) * limit));
|
|
230
|
+
messages = messages.slice(start, end);
|
|
231
|
+
} else {
|
|
232
|
+
// Default: last N messages (backward compatible)
|
|
233
|
+
messages = messages.slice(-limit);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
messages.forEach(m => { m.acked = !!acks[m.id]; });
|
|
237
|
+
// Include pagination metadata when page is requested
|
|
238
|
+
if (page > 0) {
|
|
239
|
+
return { messages, total, page, limit, pages: Math.ceil(total / limit) };
|
|
240
|
+
}
|
|
241
|
+
return messages;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function apiChannels(query) {
|
|
245
|
+
const projectPath = query.get('project') || null;
|
|
246
|
+
const channelsFile = filePath('channels.json', projectPath);
|
|
247
|
+
const channels = readJson(channelsFile);
|
|
248
|
+
if (!channels) return { general: { description: 'General channel', members: ['*'], message_count: 0 } };
|
|
249
|
+
const dataDir = resolveDataDir(projectPath);
|
|
250
|
+
const result = {};
|
|
251
|
+
for (const [name, ch] of Object.entries(channels)) {
|
|
252
|
+
let msgCount = 0;
|
|
253
|
+
const msgFile = name === 'general'
|
|
254
|
+
? filePath('history.jsonl', projectPath)
|
|
255
|
+
: path.join(dataDir, 'channel-' + name + '-history.jsonl');
|
|
256
|
+
try {
|
|
257
|
+
if (fs.existsSync(msgFile)) {
|
|
258
|
+
const content = fs.readFileSync(msgFile, 'utf8').trim();
|
|
259
|
+
if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
260
|
+
}
|
|
261
|
+
} catch {}
|
|
262
|
+
result[name] = { description: ch.description || '', members: ch.members, message_count: msgCount };
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function apiAgents(query) {
|
|
268
|
+
const projectPath = query.get('project') || null;
|
|
269
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
270
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
271
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
272
|
+
|
|
273
|
+
// Merge per-agent heartbeat files — agents write these during listen loops
|
|
274
|
+
// Without this merge, agents show as dead because agents.json has stale last_activity
|
|
275
|
+
const dataDir = resolveDataDir(projectPath);
|
|
276
|
+
try {
|
|
277
|
+
const hbFiles = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-') && f.endsWith('.json'));
|
|
278
|
+
for (const f of hbFiles) {
|
|
279
|
+
const name = f.slice(10, -5); // 'heartbeat-Backend.json' → 'Backend'
|
|
280
|
+
if (agents[name]) {
|
|
281
|
+
try {
|
|
282
|
+
const hb = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
|
|
283
|
+
if (hb.last_activity) agents[name].last_activity = hb.last_activity;
|
|
284
|
+
if (hb.pid) agents[name].pid = hb.pid;
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {}
|
|
289
|
+
const result = {};
|
|
290
|
+
|
|
291
|
+
// Build last message timestamp per agent from history
|
|
292
|
+
const lastMessageTime = {};
|
|
293
|
+
for (const m of history) {
|
|
294
|
+
lastMessageTime[m.from] = m.timestamp;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
298
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
299
|
+
const lastActivity = info.last_activity || info.timestamp;
|
|
300
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
301
|
+
const profile = profiles[name] || {};
|
|
302
|
+
const isLocal = (() => { try { process.kill(info.pid, 0); return true; } catch { return false; } })();
|
|
303
|
+
result[name] = {
|
|
304
|
+
pid: info.pid,
|
|
305
|
+
alive,
|
|
306
|
+
registered_at: info.timestamp,
|
|
307
|
+
last_activity: lastActivity,
|
|
308
|
+
last_message: lastMessageTime[name] || null,
|
|
309
|
+
idle_seconds: alive ? idleSeconds : null,
|
|
310
|
+
status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
|
|
311
|
+
listening_since: info.listening_since || null,
|
|
312
|
+
is_listening: !!(info.listening_since && alive),
|
|
313
|
+
provider: info.provider || 'unknown',
|
|
314
|
+
branch: info.branch || 'main',
|
|
315
|
+
display_name: profile.display_name || name,
|
|
316
|
+
avatar: profile.avatar || getDefaultAvatar(name),
|
|
317
|
+
role: profile.role || '',
|
|
318
|
+
bio: profile.bio || '',
|
|
319
|
+
appearance: profile.appearance || {},
|
|
320
|
+
hostname: info.hostname || null,
|
|
321
|
+
is_remote: !isLocal && alive,
|
|
322
|
+
};
|
|
323
|
+
// Include workspace status for agent intent board
|
|
324
|
+
try {
|
|
325
|
+
const wsPath = path.join(resolveDataDir(projectPath), 'workspaces', name + '.json');
|
|
326
|
+
if (fs.existsSync(wsPath)) {
|
|
327
|
+
const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
|
|
328
|
+
if (ws._status) result[name].current_status = ws._status;
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function apiStatus(query) {
|
|
336
|
+
const projectPath = query.get('project') || null;
|
|
337
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
338
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
339
|
+
const threads = new Set();
|
|
340
|
+
history.forEach(m => { if (m.thread_id) threads.add(m.thread_id); });
|
|
341
|
+
|
|
342
|
+
const agentEntries = Object.entries(agents);
|
|
343
|
+
const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid, a.last_activity)).length;
|
|
344
|
+
const sleepingCount = agentEntries.filter(([, a]) => {
|
|
345
|
+
if (!isPidAlive(a.pid, a.last_activity)) return false;
|
|
346
|
+
const lastActivity = a.last_activity || a.timestamp;
|
|
347
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
348
|
+
return idleSeconds > 60;
|
|
349
|
+
}).length;
|
|
350
|
+
|
|
351
|
+
// Include managed mode status if active
|
|
352
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
353
|
+
const result = {
|
|
354
|
+
messageCount: history.length,
|
|
355
|
+
agentCount: agentEntries.length,
|
|
356
|
+
aliveCount,
|
|
357
|
+
sleepingCount,
|
|
358
|
+
threadCount: threads.size,
|
|
359
|
+
conversation_mode: config.conversation_mode || 'direct',
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (config.conversation_mode === 'managed' && config.managed) {
|
|
363
|
+
result.managed = {
|
|
364
|
+
manager: config.managed.manager,
|
|
365
|
+
phase: config.managed.phase,
|
|
366
|
+
floor: config.managed.floor,
|
|
367
|
+
turn_current: config.managed.turn_current,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function apiStats(query) {
|
|
375
|
+
const projectPath = query.get('project') || null;
|
|
376
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
377
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
378
|
+
|
|
379
|
+
// Per-agent stats — only count messages from agents still in agents.json
|
|
380
|
+
const perAgent = {};
|
|
381
|
+
const knownAgentNames = new Set(Object.keys(agents));
|
|
382
|
+
knownAgentNames.add('__system__');
|
|
383
|
+
knownAgentNames.add('Dashboard');
|
|
384
|
+
let totalMessages = 0;
|
|
385
|
+
const hourBuckets = new Array(24).fill(0);
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < history.length; i++) {
|
|
388
|
+
const m = history[i];
|
|
389
|
+
const from = m.from || 'unknown';
|
|
390
|
+
if (!knownAgentNames.has(from)) continue; // skip removed agents
|
|
391
|
+
if (!perAgent[from]) {
|
|
392
|
+
perAgent[from] = { messages: 0, responseTimes: [], hours: new Array(24).fill(0) };
|
|
393
|
+
}
|
|
394
|
+
totalMessages++;
|
|
395
|
+
perAgent[from].messages++;
|
|
396
|
+
const ts = new Date(m.timestamp);
|
|
397
|
+
const hour = ts.getHours();
|
|
398
|
+
perAgent[from].hours[hour]++;
|
|
399
|
+
hourBuckets[hour]++;
|
|
400
|
+
|
|
401
|
+
// Compute response time if this is a reply
|
|
402
|
+
if (m.reply_to) {
|
|
403
|
+
for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
|
|
404
|
+
if (history[j].id === m.reply_to) {
|
|
405
|
+
const delta = ts.getTime() - new Date(history[j].timestamp).getTime();
|
|
406
|
+
if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Build per-agent summary — only include agents currently in agents.json
|
|
414
|
+
const agentStats = {};
|
|
415
|
+
let busiestAgent = null;
|
|
416
|
+
let busiestCount = 0;
|
|
417
|
+
for (const [name, data] of Object.entries(perAgent)) {
|
|
418
|
+
const avgResponseMs = data.responseTimes.length
|
|
419
|
+
? Math.round(data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length)
|
|
420
|
+
: null;
|
|
421
|
+
const peakHour = data.hours.indexOf(Math.max(...data.hours));
|
|
422
|
+
agentStats[name] = {
|
|
423
|
+
messages: data.messages,
|
|
424
|
+
avg_response_ms: avgResponseMs,
|
|
425
|
+
peak_hour: peakHour,
|
|
426
|
+
};
|
|
427
|
+
if (data.messages > busiestCount) {
|
|
428
|
+
busiestCount = data.messages;
|
|
429
|
+
busiestAgent = name;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Conversation velocity (messages per minute over last 10 minutes)
|
|
434
|
+
const tenMinAgo = Date.now() - 600000;
|
|
435
|
+
const recentCount = history.filter(m => new Date(m.timestamp).getTime() > tenMinAgo).length;
|
|
436
|
+
const velocity = Math.round((recentCount / 10) * 10) / 10;
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
total_messages: totalMessages,
|
|
440
|
+
busiest_agent: busiestAgent,
|
|
441
|
+
velocity_per_min: velocity,
|
|
442
|
+
hour_distribution: hourBuckets,
|
|
443
|
+
agents: agentStats,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// --- v3.4: Notification Tracking ---
|
|
448
|
+
let notificationHistory = [];
|
|
449
|
+
let prevAgentState = {};
|
|
450
|
+
|
|
451
|
+
function generateNotifications(currentAgents) {
|
|
452
|
+
const crypto = require('crypto');
|
|
453
|
+
const now = new Date().toISOString();
|
|
454
|
+
|
|
455
|
+
for (const [name, agent] of Object.entries(currentAgents)) {
|
|
456
|
+
const prev = prevAgentState[name];
|
|
457
|
+
const isAlive = agent.pid ? isPidAlive(agent.pid, agent.last_activity) : false;
|
|
458
|
+
const isListening = !!agent.listening;
|
|
459
|
+
|
|
460
|
+
if (prev) {
|
|
461
|
+
if (!prev.alive && isAlive) {
|
|
462
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
463
|
+
}
|
|
464
|
+
if (prev.alive && !isAlive) {
|
|
465
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_offline', agent: name, message: `${name} went offline`, timestamp: now });
|
|
466
|
+
}
|
|
467
|
+
if (!prev.listening && isListening) {
|
|
468
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_listening', agent: name, message: `${name} started listening`, timestamp: now });
|
|
469
|
+
}
|
|
470
|
+
if (prev.listening && !isListening) {
|
|
471
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_busy', agent: name, message: `${name} stopped listening`, timestamp: now });
|
|
472
|
+
}
|
|
473
|
+
} else if (isAlive) {
|
|
474
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
prevAgentState[name] = { alive: isAlive, listening: isListening };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Trim to max 50
|
|
481
|
+
if (notificationHistory.length > 50) {
|
|
482
|
+
notificationHistory = notificationHistory.slice(-50);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function apiNotifications() {
|
|
487
|
+
return notificationHistory;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// --- v3.4: Performance Scoring ---
|
|
491
|
+
function apiScores(query) {
|
|
492
|
+
const projectPath = query.get('project') || null;
|
|
493
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
494
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
495
|
+
|
|
496
|
+
const perAgent = {};
|
|
497
|
+
const totalMessages = history.length;
|
|
498
|
+
const allAgentNames = new Set();
|
|
499
|
+
|
|
500
|
+
// Gather per-agent data
|
|
501
|
+
for (let i = 0; i < history.length; i++) {
|
|
502
|
+
const m = history[i];
|
|
503
|
+
const from = m.from || 'unknown';
|
|
504
|
+
allAgentNames.add(from);
|
|
505
|
+
if (m.to) allAgentNames.add(m.to);
|
|
506
|
+
if (!perAgent[from]) perAgent[from] = { messages: 0, responseTimes: [], peers: new Set() };
|
|
507
|
+
perAgent[from].messages++;
|
|
508
|
+
if (m.to) perAgent[from].peers.add(m.to);
|
|
509
|
+
|
|
510
|
+
if (m.reply_to) {
|
|
511
|
+
for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
|
|
512
|
+
if (history[j].id === m.reply_to) {
|
|
513
|
+
const delta = new Date(m.timestamp).getTime() - new Date(history[j].timestamp).getTime();
|
|
514
|
+
if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta / 1000);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const totalAgents = allAgentNames.size;
|
|
522
|
+
const maxMessages = Math.max(1, ...Object.values(perAgent).map(d => d.messages));
|
|
523
|
+
|
|
524
|
+
const result = {};
|
|
525
|
+
const scores = [];
|
|
526
|
+
|
|
527
|
+
for (const [name, data] of Object.entries(perAgent)) {
|
|
528
|
+
const avgResponseSec = data.responseTimes.length
|
|
529
|
+
? data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length
|
|
530
|
+
: Infinity;
|
|
531
|
+
|
|
532
|
+
// Responsiveness (30 pts)
|
|
533
|
+
let responsiveness;
|
|
534
|
+
if (avgResponseSec < 10) responsiveness = 30;
|
|
535
|
+
else if (avgResponseSec < 30) responsiveness = 25;
|
|
536
|
+
else if (avgResponseSec < 60) responsiveness = 20;
|
|
537
|
+
else if (avgResponseSec < 120) responsiveness = 15;
|
|
538
|
+
else responsiveness = 10;
|
|
539
|
+
|
|
540
|
+
// Activity (30 pts) — linear scale relative to top agent
|
|
541
|
+
const activity = Math.round((data.messages / maxMessages) * 30);
|
|
542
|
+
|
|
543
|
+
// Reliability (20 pts) — uptime based on agent registration
|
|
544
|
+
let reliability = 10;
|
|
545
|
+
const agentInfo = agents[name];
|
|
546
|
+
if (agentInfo) {
|
|
547
|
+
const isAlive = agentInfo.pid ? isPidAlive(agentInfo.pid, agentInfo.last_activity) : false;
|
|
548
|
+
const registered = new Date(agentInfo.registered_at || agentInfo.last_activity).getTime();
|
|
549
|
+
const totalTime = Date.now() - registered;
|
|
550
|
+
if (totalTime > 0 && isAlive) {
|
|
551
|
+
const lastAct = new Date(agentInfo.last_activity).getTime();
|
|
552
|
+
const activeTime = lastAct - registered;
|
|
553
|
+
const uptime = Math.min(1, activeTime / totalTime);
|
|
554
|
+
if (uptime > 0.95) reliability = 20;
|
|
555
|
+
else if (uptime > 0.80) reliability = 15;
|
|
556
|
+
else if (uptime > 0.50) reliability = 10;
|
|
557
|
+
else reliability = 5;
|
|
558
|
+
} else if (!isAlive) {
|
|
559
|
+
reliability = 5;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Collaboration (20 pts)
|
|
564
|
+
const collaboration = totalAgents > 1
|
|
565
|
+
? Math.round((data.peers.size / (totalAgents - 1)) * 20)
|
|
566
|
+
: 20;
|
|
567
|
+
|
|
568
|
+
const score = responsiveness + activity + reliability + collaboration;
|
|
569
|
+
result[name] = { score, responsiveness, activity, reliability, collaboration };
|
|
570
|
+
scores.push({ name, score });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Add ranks
|
|
574
|
+
scores.sort((a, b) => b.score - a.score);
|
|
575
|
+
scores.forEach((s, i) => { result[s.name].rank = i + 1; });
|
|
576
|
+
|
|
577
|
+
return { agents: result };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// --- v3.4: Cross-Project Search ---
|
|
581
|
+
function apiSearchAll(query) {
|
|
582
|
+
const q = (query.get('q') || '').toLowerCase();
|
|
583
|
+
const limit = Math.min(parseInt(query.get('limit') || '50', 10), 200);
|
|
584
|
+
if (!q) return { error: 'Missing "q" parameter' };
|
|
585
|
+
|
|
586
|
+
const projects = getProjects();
|
|
587
|
+
// Add default project
|
|
588
|
+
const allProjects = [{ name: path.basename(process.cwd()), path: null }];
|
|
589
|
+
for (const p of projects) allProjects.push(p);
|
|
590
|
+
|
|
591
|
+
const results = [];
|
|
592
|
+
let total = 0;
|
|
593
|
+
|
|
594
|
+
for (const proj of allProjects) {
|
|
595
|
+
if (proj.path && !validateProjectPath(proj.path)) continue;
|
|
596
|
+
const history = readJsonl(filePath('history.jsonl', proj.path));
|
|
597
|
+
const matches = [];
|
|
598
|
+
for (const m of history) {
|
|
599
|
+
if (matches.length >= limit) break;
|
|
600
|
+
const content = (m.content || '').toLowerCase();
|
|
601
|
+
const from = (m.from || '').toLowerCase();
|
|
602
|
+
const to = (m.to || '').toLowerCase();
|
|
603
|
+
if (content.includes(q) || from.includes(q) || to.includes(q)) {
|
|
604
|
+
matches.push({ id: m.id, from: m.from, to: m.to, content: m.content, timestamp: m.timestamp });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (matches.length > 0) {
|
|
608
|
+
results.push({ project: proj.name, path: proj.path || process.cwd(), messages: matches });
|
|
609
|
+
total += matches.length;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { results, total };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- v3.4: Replay Export ---
|
|
617
|
+
function apiExportReplay(query) {
|
|
618
|
+
const projectPath = query.get('project') || null;
|
|
619
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
620
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
621
|
+
|
|
622
|
+
const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
|
|
623
|
+
const agentColors = {};
|
|
624
|
+
let colorIdx = 0;
|
|
625
|
+
for (const m of history) {
|
|
626
|
+
if (!agentColors[m.from]) agentColors[m.from] = colors[colorIdx++ % colors.length];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const messagesJson = JSON.stringify(history.map(m => ({
|
|
630
|
+
from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#58a6ff'
|
|
631
|
+
})));
|
|
632
|
+
|
|
633
|
+
return `<!DOCTYPE html>
|
|
634
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
635
|
+
<title>Neohive — Replay</title>
|
|
636
|
+
<style>
|
|
637
|
+
:root{--bg:#0d1117;--surface:#161b22;--surface-2:#21262d;--border:#30363d;--text:#e6edf3;--dim:#8b949e}
|
|
638
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
639
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
|
640
|
+
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;justify-content:space-between}
|
|
641
|
+
.title{font-size:16px;font-weight:700;color:var(--text)}
|
|
642
|
+
.controls{display:flex;gap:8px;align-items:center}
|
|
643
|
+
.controls button{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 14px;cursor:pointer;font-size:13px}
|
|
644
|
+
.controls button:hover{background:var(--border)}
|
|
645
|
+
.controls select{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:13px}
|
|
646
|
+
.messages{max-width:800px;margin:20px auto;padding:0 16px}
|
|
647
|
+
.msg{opacity:0;transform:translateY(8px);transition:opacity 0.3s,transform 0.3s;margin-bottom:12px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
|
|
648
|
+
.msg.visible{opacity:1;transform:translateY(0)}
|
|
649
|
+
.msg-header{display:flex;gap:8px;align-items:baseline;margin-bottom:4px;font-size:13px}
|
|
650
|
+
.msg-from{font-weight:700}
|
|
651
|
+
.msg-to{color:var(--dim)}
|
|
652
|
+
.msg-time{color:var(--dim);margin-left:auto;font-size:11px}
|
|
653
|
+
.msg-content{font-size:14px;white-space:pre-wrap;word-break:break-word}
|
|
654
|
+
.msg-content code{background:var(--surface-2);padding:1px 5px;border-radius:3px;font-size:0.9em}
|
|
655
|
+
.msg-content strong{font-weight:700}
|
|
656
|
+
.progress{font-size:12px;color:var(--dim)}
|
|
657
|
+
</style></head><body>
|
|
658
|
+
<div class="header">
|
|
659
|
+
<span class="title">Neohive — Replay</span>
|
|
660
|
+
<div class="controls">
|
|
661
|
+
<button id="btn" onclick="toggle()">Pause</button>
|
|
662
|
+
<label><span style="color:var(--dim);font-size:12px">Speed:</span>
|
|
663
|
+
<select id="speed" onchange="setSpeed(this.value)">
|
|
664
|
+
<option value="2000">Slow</option><option value="1000" selected>Normal</option><option value="500">Fast</option><option value="200">Very Fast</option>
|
|
665
|
+
</select></label>
|
|
666
|
+
<span class="progress" id="progress">0 / 0</span>
|
|
667
|
+
</div></div>
|
|
668
|
+
<div class="messages" id="messages"></div>
|
|
669
|
+
<script>
|
|
670
|
+
var msgs=${messagesJson.replace(/<\//g, '<\\/')};
|
|
671
|
+
var idx=0,playing=true,timer=null,speed=1000;
|
|
672
|
+
function md(s){return s.replace(/\`\`\`[\\s\\S]*?\`\`\`/g,function(m){return '<pre><code>'+m.slice(3,-3).replace(/^\\w*\\n/,'')+'</code></pre>'}).replace(/\`([^\`]+)\`/g,'<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>').replace(/^### (.+)$/gm,'<h4 style="margin:8px 0 4px;font-size:14px">$1</h4>').replace(/^## (.+)$/gm,'<h3 style="margin:8px 0 4px;font-size:15px">$1</h3>').replace(/^# (.+)$/gm,'<h2 style="margin:8px 0 4px;font-size:16px">$1</h2>')}
|
|
673
|
+
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
674
|
+
function showNext(){if(idx>=msgs.length){playing=false;document.getElementById('btn').textContent='Done';return}
|
|
675
|
+
var m=msgs[idx],el=document.createElement('div');el.className='msg';
|
|
676
|
+
var t=new Date(m.timestamp);var time=t.toLocaleTimeString();
|
|
677
|
+
el.innerHTML='<div class="msg-header"><span class="msg-from" style="color:'+m.color+'">'+esc(m.from)+'</span><span class="msg-to">→ '+esc(m.to||'all')+'</span><span class="msg-time">'+time+'</span></div><div class="msg-content">'+md(esc(m.content))+'</div>';
|
|
678
|
+
document.getElementById('messages').appendChild(el);
|
|
679
|
+
requestAnimationFrame(function(){el.classList.add('visible')});
|
|
680
|
+
el.scrollIntoView({behavior:'smooth',block:'end'});
|
|
681
|
+
idx++;document.getElementById('progress').textContent=idx+' / '+msgs.length;
|
|
682
|
+
if(playing)timer=setTimeout(showNext,speed)}
|
|
683
|
+
function toggle(){if(idx>=msgs.length){idx=0;document.getElementById('messages').innerHTML='';playing=true;document.getElementById('btn').textContent='Pause';showNext();return}
|
|
684
|
+
playing=!playing;document.getElementById('btn').textContent=playing?'Pause':'Play';if(playing)showNext();else clearTimeout(timer)}
|
|
685
|
+
function setSpeed(v){speed=parseInt(v)}
|
|
686
|
+
showNext();
|
|
687
|
+
</script></body></html>`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function apiReset(query) {
|
|
691
|
+
const projectPath = query.get('project') || null;
|
|
692
|
+
const dataDir = resolveDataDir(projectPath);
|
|
693
|
+
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json', 'config.json'];
|
|
694
|
+
for (const f of fixedFiles) {
|
|
695
|
+
const p = path.join(dataDir, f);
|
|
696
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
697
|
+
}
|
|
698
|
+
if (fs.existsSync(dataDir)) {
|
|
699
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
700
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
701
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
702
|
+
}
|
|
703
|
+
if (f.startsWith('branch-') && (f.endsWith('-messages.jsonl') || f.endsWith('-history.jsonl'))) {
|
|
704
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Remove workspaces dir
|
|
709
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
710
|
+
if (fs.existsSync(wsDir)) {
|
|
711
|
+
for (const f of fs.readdirSync(wsDir)) fs.unlinkSync(path.join(wsDir, f));
|
|
712
|
+
try { fs.rmdirSync(wsDir); } catch {}
|
|
713
|
+
}
|
|
714
|
+
return { success: true };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function apiClearMessages(query) {
|
|
718
|
+
const projectPath = query.get('project') || null;
|
|
719
|
+
const dataDir = resolveDataDir(projectPath);
|
|
720
|
+
for (const f of ['messages.jsonl', 'history.jsonl']) {
|
|
721
|
+
const p = path.join(dataDir, f);
|
|
722
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
723
|
+
}
|
|
724
|
+
if (fs.existsSync(dataDir)) {
|
|
725
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
726
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
727
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { success: true };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function apiNewConversation(query) {
|
|
735
|
+
const projectPath = query.get('project') || null;
|
|
736
|
+
const dataDir = resolveDataDir(projectPath);
|
|
737
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
738
|
+
if (!fs.existsSync(convDir)) fs.mkdirSync(convDir, { recursive: true });
|
|
739
|
+
const now = new Date();
|
|
740
|
+
const stamp = now.toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '') + '-' + Math.random().toString(36).slice(2, 6);
|
|
741
|
+
const baseName = 'conversation-' + stamp;
|
|
742
|
+
const msgSrc = path.join(dataDir, 'messages.jsonl');
|
|
743
|
+
const histSrc = path.join(dataDir, 'history.jsonl');
|
|
744
|
+
if (fs.existsSync(msgSrc)) fs.copyFileSync(msgSrc, path.join(convDir, baseName + '.jsonl'));
|
|
745
|
+
if (fs.existsSync(histSrc)) fs.copyFileSync(histSrc, path.join(convDir, baseName + '-history.jsonl'));
|
|
746
|
+
// Clean up current files
|
|
747
|
+
if (fs.existsSync(msgSrc)) fs.unlinkSync(msgSrc);
|
|
748
|
+
if (fs.existsSync(histSrc)) fs.unlinkSync(histSrc);
|
|
749
|
+
if (fs.existsSync(dataDir)) {
|
|
750
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
751
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
752
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return { success: true, archived: baseName };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function apiListConversations(query) {
|
|
760
|
+
const projectPath = query.get('project') || null;
|
|
761
|
+
const dataDir = resolveDataDir(projectPath);
|
|
762
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
763
|
+
if (!fs.existsSync(convDir)) return { conversations: [] };
|
|
764
|
+
const files = fs.readdirSync(convDir).filter(f => f.startsWith('conversation-') && f.endsWith('.jsonl') && !f.endsWith('-history.jsonl'));
|
|
765
|
+
const conversations = files.map(f => {
|
|
766
|
+
const name = f.replace('.jsonl', '');
|
|
767
|
+
const dateStr = name.replace('conversation-', '').replace(/-/g, function(m, i) {
|
|
768
|
+
// First 2 dashes are date separators, 3rd is T separator, rest are time separators
|
|
769
|
+
return m;
|
|
770
|
+
});
|
|
771
|
+
// Parse date from stamp: YYYY-MM-DDTHH-MM-SS
|
|
772
|
+
const parts = name.replace('conversation-', '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})$/);
|
|
773
|
+
let date = '';
|
|
774
|
+
if (parts) {
|
|
775
|
+
date = parts[1] + '-' + parts[2] + '-' + parts[3] + 'T' + parts[4] + ':' + parts[5] + ':' + parts[6];
|
|
776
|
+
}
|
|
777
|
+
let messageCount = 0;
|
|
778
|
+
try {
|
|
779
|
+
const content = fs.readFileSync(path.join(convDir, f), 'utf8').trim();
|
|
780
|
+
if (content) messageCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
781
|
+
} catch {}
|
|
782
|
+
return { name, date, messageCount };
|
|
783
|
+
});
|
|
784
|
+
conversations.sort((a, b) => b.date.localeCompare(a.date));
|
|
785
|
+
return { conversations };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function apiLoadConversation(query) {
|
|
789
|
+
const projectPath = query.get('project') || null;
|
|
790
|
+
const name = query.get('name');
|
|
791
|
+
if (!name || /[^a-zA-Z0-9_-]/.test(name) || name.length > 100) {
|
|
792
|
+
return { error: 'Invalid conversation name' };
|
|
793
|
+
}
|
|
794
|
+
const dataDir = resolveDataDir(projectPath);
|
|
795
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
796
|
+
const msgFile = path.join(convDir, name + '.jsonl');
|
|
797
|
+
const histFile = path.join(convDir, name + '-history.jsonl');
|
|
798
|
+
if (!fs.existsSync(msgFile)) return { error: 'Conversation not found' };
|
|
799
|
+
// Use file lock to prevent corruption during concurrent writes
|
|
800
|
+
const lockPath = path.join(dataDir, 'messages.jsonl.lock');
|
|
801
|
+
try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); } catch {
|
|
802
|
+
return { error: 'Messages file is locked by another operation. Try again.' };
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
fs.copyFileSync(msgFile, path.join(dataDir, 'messages.jsonl'));
|
|
806
|
+
if (fs.existsSync(histFile)) {
|
|
807
|
+
fs.copyFileSync(histFile, path.join(dataDir, 'history.jsonl'));
|
|
808
|
+
} else {
|
|
809
|
+
const hp = path.join(dataDir, 'history.jsonl');
|
|
810
|
+
if (fs.existsSync(hp)) fs.unlinkSync(hp);
|
|
811
|
+
}
|
|
812
|
+
// Clear stale consumed offsets
|
|
813
|
+
if (fs.existsSync(dataDir)) {
|
|
814
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
815
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
816
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
} finally {
|
|
821
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
822
|
+
}
|
|
823
|
+
return { success: true };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Inject a message from the dashboard (system message or nudge to an agent)
|
|
827
|
+
function apiInjectMessage(body, query) {
|
|
828
|
+
const projectPath = query.get('project') || null;
|
|
829
|
+
const dataDir = resolveDataDir(projectPath);
|
|
830
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
831
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
832
|
+
|
|
833
|
+
if (!body.to || !body.content) {
|
|
834
|
+
return { error: 'Missing "to" and/or "content" fields' };
|
|
835
|
+
}
|
|
836
|
+
if (typeof body.content !== 'string' || body.content.length > 100000) {
|
|
837
|
+
return { error: 'Message content too long (max 100KB)' };
|
|
838
|
+
}
|
|
839
|
+
// Strip control characters to prevent injection
|
|
840
|
+
body.content = body.content.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
841
|
+
if (body.to !== '__all__' && !/^[a-zA-Z0-9_-]{1,20}$/.test(body.to)) {
|
|
842
|
+
return { error: 'Invalid agent name' };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
846
|
+
const fromName = 'Dashboard';
|
|
847
|
+
const now = new Date().toISOString();
|
|
848
|
+
|
|
849
|
+
// Broadcast to all agents
|
|
850
|
+
if (body.to === '__all__') {
|
|
851
|
+
const agents = readJson(path.join(dataDir, 'agents.json'));
|
|
852
|
+
const ids = [];
|
|
853
|
+
for (const name of Object.keys(agents)) {
|
|
854
|
+
const msg = {
|
|
855
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
856
|
+
from: fromName,
|
|
857
|
+
to: name,
|
|
858
|
+
content: body.content,
|
|
859
|
+
timestamp: now,
|
|
860
|
+
system: true,
|
|
861
|
+
};
|
|
862
|
+
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
863
|
+
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
864
|
+
ids.push(msg.id);
|
|
865
|
+
}
|
|
866
|
+
return { success: true, messageIds: ids, broadcast: true };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const msg = {
|
|
870
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
871
|
+
from: fromName,
|
|
872
|
+
to: body.to,
|
|
873
|
+
content: body.content,
|
|
874
|
+
timestamp: now,
|
|
875
|
+
system: true,
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
879
|
+
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
880
|
+
|
|
881
|
+
return { success: true, messageId: msg.id };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Multi-project management
|
|
885
|
+
function apiProjects() {
|
|
886
|
+
return getProjects();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function apiAddProject(body) {
|
|
890
|
+
if (!body.path) return { error: 'Missing "path" field' };
|
|
891
|
+
const absPath = path.resolve(body.path);
|
|
892
|
+
|
|
893
|
+
// Reject root directories and system paths
|
|
894
|
+
const normalized = absPath.replace(/\\/g, '/');
|
|
895
|
+
if (normalized === '/' || normalized === 'C:/' || /^[A-Z]:\/$/i.test(normalized) || /^[A-Z]:\/Windows/i.test(normalized) || normalized.startsWith('/etc') || normalized.startsWith('/usr') || normalized.startsWith('/sys')) {
|
|
896
|
+
return { error: 'Cannot monitor system directories' };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
|
|
900
|
+
|
|
901
|
+
// Any existing directory can be added as a project — user explicitly chose it
|
|
902
|
+
|
|
903
|
+
const projects = getProjects();
|
|
904
|
+
const name = body.name || path.basename(absPath);
|
|
905
|
+
if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
|
|
906
|
+
|
|
907
|
+
// Create .neohive directory if it doesn't exist
|
|
908
|
+
const abDir = path.join(absPath, '.neohive');
|
|
909
|
+
if (!fs.existsSync(abDir)) fs.mkdirSync(abDir, { recursive: true });
|
|
910
|
+
|
|
911
|
+
// Set up MCP config so agents can use it
|
|
912
|
+
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
913
|
+
ensureMCPConfig('claude', serverPath, absPath);
|
|
914
|
+
|
|
915
|
+
projects.push({ name, path: absPath, added_at: new Date().toISOString() });
|
|
916
|
+
saveProjects(projects);
|
|
917
|
+
return { success: true, project: { name, path: absPath } };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function apiRemoveProject(body) {
|
|
921
|
+
if (!body.path) return { error: 'Missing "path" field' };
|
|
922
|
+
const absPath = path.resolve(body.path);
|
|
923
|
+
let projects = getProjects();
|
|
924
|
+
const before = projects.length;
|
|
925
|
+
projects = projects.filter(p => p.path !== absPath);
|
|
926
|
+
if (projects.length === before) return { error: 'Project not found' };
|
|
927
|
+
saveProjects(projects);
|
|
928
|
+
return { success: true };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Export conversation as self-contained HTML
|
|
932
|
+
function apiExportHtml(query) {
|
|
933
|
+
const projectPath = query.get('project') || null;
|
|
934
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
935
|
+
const acks = readJson(filePath('acks.json', projectPath));
|
|
936
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
937
|
+
history.forEach(m => { m.acked = !!acks[m.id]; });
|
|
938
|
+
|
|
939
|
+
const agentNames = [...new Set(history.map(m => m.from))];
|
|
940
|
+
const exportDate = new Date().toLocaleString();
|
|
941
|
+
|
|
942
|
+
const startTime = history.length > 0 ? new Date(history[0].timestamp).toLocaleString() : '';
|
|
943
|
+
const endTime = history.length > 0 ? new Date(history[history.length - 1].timestamp).toLocaleString() : '';
|
|
944
|
+
const duration = history.length > 1 ? Math.round((new Date(history[history.length-1].timestamp) - new Date(history[0].timestamp)) / 60000) : 0;
|
|
945
|
+
const durationStr = duration > 60 ? Math.floor(duration/60) + 'h ' + (duration%60) + 'm' : duration + ' minutes';
|
|
946
|
+
|
|
947
|
+
return `<!DOCTYPE html>
|
|
948
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
949
|
+
<title>Neohive — Conversation Export</title>
|
|
950
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%230d1117'/><path d='M20 30 Q20 20 30 20 H70 Q80 20 80 30 V55 Q80 65 70 65 H55 L40 80 V65 H30 Q20 65 20 55Z' fill='%2358a6ff'/><circle cx='38' cy='42' r='5' fill='%230d1117'/><circle cx='55' cy='42' r='5' fill='%230d1117'/></svg>">
|
|
951
|
+
<style>
|
|
952
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
953
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e6edf3;min-height:100vh}
|
|
954
|
+
.export-header{background:linear-gradient(180deg,#0f0f18 0%,#0a0a0f 100%);padding:40px 24px 32px;text-align:center;border-bottom:1px solid #1e1e2e}
|
|
955
|
+
.logo{font-size:28px;font-weight:800;background:linear-gradient(135deg,#58a6ff,#bc8cff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-1px}
|
|
956
|
+
.export-meta{margin-top:12px;display:flex;justify-content:center;gap:20px;flex-wrap:wrap}
|
|
957
|
+
.meta-item{font-size:12px;color:#8888a0}
|
|
958
|
+
.meta-val{color:#58a6ff;font-weight:600}
|
|
959
|
+
.agent-chips{display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
|
960
|
+
.agent-chip{display:flex;align-items:center;gap:6px;background:#161622;border:1px solid #1e1e2e;border-radius:20px;padding:4px 12px 4px 4px;font-size:12px}
|
|
961
|
+
.agent-chip .dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff}
|
|
962
|
+
.messages{max-width:860px;margin:0 auto;padding:20px 24px}
|
|
963
|
+
.msg{display:flex;gap:10px;padding:10px 14px;border-radius:8px;margin-bottom:2px}
|
|
964
|
+
.msg:hover{background:#161622}
|
|
965
|
+
.avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:#fff;flex-shrink:0}
|
|
966
|
+
.msg-body{flex:1;min-width:0}
|
|
967
|
+
.msg-header{display:flex;gap:6px;align-items:baseline;margin-bottom:3px;flex-wrap:wrap}
|
|
968
|
+
.msg-from{font-weight:600;font-size:13px}
|
|
969
|
+
.msg-arrow{color:#555568;font-size:11px}
|
|
970
|
+
.msg-to{font-size:12px;color:#8888a0}
|
|
971
|
+
.msg-time{font-size:10px;color:#555568}
|
|
972
|
+
.msg-content{font-size:13px;line-height:1.6;word-break:break-word}
|
|
973
|
+
.msg-content strong{font-weight:700}
|
|
974
|
+
.msg-content em{font-style:italic;color:#8888a0}
|
|
975
|
+
.msg-content code{background:#1e1e2e;padding:1px 5px;border-radius:4px;font-size:12px;font-family:Consolas,monospace;color:#d29922}
|
|
976
|
+
.msg-content pre{background:#0f0f18;border:1px solid #1e1e2e;border-radius:6px;padding:12px;margin:6px 0;overflow-x:auto;font-size:12px;font-family:Consolas,monospace}
|
|
977
|
+
.msg-content pre code{background:none;color:#e6edf3;padding:0}
|
|
978
|
+
.msg-content h1,.msg-content h2,.msg-content h3{margin:8px 0 4px;font-weight:700}
|
|
979
|
+
.msg-content h1{font-size:18px;border-bottom:1px solid #1e1e2e;padding-bottom:4px}
|
|
980
|
+
.msg-content h2{font-size:16px}
|
|
981
|
+
.msg-content h3{font-size:14px}
|
|
982
|
+
.msg-content ul,.msg-content ol{padding-left:20px;margin:4px 0}
|
|
983
|
+
.msg-content table{border-collapse:collapse;margin:6px 0;font-size:12px}
|
|
984
|
+
.msg-content th,.msg-content td{border:1px solid #1e1e2e;padding:4px 8px;text-align:left}
|
|
985
|
+
.msg-content th{background:#161622}
|
|
986
|
+
.badge{font-size:9px;padding:1px 5px;border-radius:8px;font-weight:600}
|
|
987
|
+
.badge-ack{background:rgba(63,185,80,0.15);color:#3fb950}
|
|
988
|
+
.date-sep{display:flex;align-items:center;gap:12px;padding:12px 14px 6px;color:#555568;font-size:11px;font-weight:600}
|
|
989
|
+
.date-sep::before,.date-sep::after{content:'';flex:1;height:1px;background:#1e1e2e}
|
|
990
|
+
.footer{border-top:1px solid #1e1e2e;padding:24px;text-align:center;font-size:11px;color:#555568}
|
|
991
|
+
.footer a{color:#8888a0;text-decoration:none}
|
|
992
|
+
.footer a:hover{color:#58a6ff}
|
|
993
|
+
</style></head><body>
|
|
994
|
+
<div class="export-header">
|
|
995
|
+
<div class="logo">Neohive</div>
|
|
996
|
+
<div class="export-meta">
|
|
997
|
+
<span class="meta-item"><span class="meta-val">${history.length}</span> messages</span>
|
|
998
|
+
<span class="meta-item"><span class="meta-val">${agentNames.length}</span> agents</span>
|
|
999
|
+
<span class="meta-item"><span class="meta-val">${durationStr}</span> duration</span>
|
|
1000
|
+
<span class="meta-item">Exported ${htmlEscape(exportDate)}</span>
|
|
1001
|
+
</div>
|
|
1002
|
+
<div class="agent-chips" id="agent-chips"></div>
|
|
1003
|
+
</div>
|
|
1004
|
+
<div class="messages" id="messages"></div>
|
|
1005
|
+
<div class="footer">Generated by <a href="https://github.com/fakiho/neohive" target="_blank">Neohive</a> · BSL 1.1</div>
|
|
1006
|
+
<script>
|
|
1007
|
+
var COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#f778ba','#79c0ff','#7ee787','#e3b341','#ffa198'];
|
|
1008
|
+
var colorMap={},ci=0;
|
|
1009
|
+
var data=${JSON.stringify(history).replace(/<\//g, '<\\/')};
|
|
1010
|
+
function esc(t){var d=document.createElement('div');d.textContent=t;return d.innerHTML}
|
|
1011
|
+
function fmt(t){
|
|
1012
|
+
var h=esc(t);
|
|
1013
|
+
h=h.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g,function(_,l,c){return '<pre><code>'+c+'</code></pre>'});
|
|
1014
|
+
h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
|
|
1015
|
+
h=h.replace(/\\*\\*\\*(.+?)\\*\\*\\*/g,'<strong><em>$1</em></strong>');
|
|
1016
|
+
h=h.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>');
|
|
1017
|
+
h=h.replace(/\\*(.+?)\\*/g,'<em>$1</em>');
|
|
1018
|
+
h=h.replace(/^### (.+)/gm,'<h3>$1</h3>');
|
|
1019
|
+
h=h.replace(/^## (.+)/gm,'<h2>$1</h2>');
|
|
1020
|
+
h=h.replace(/^# (.+)/gm,'<h1>$1</h1>');
|
|
1021
|
+
h=h.replace(/^[\\-\\*] (.+)/gm,'<li>$1</li>');
|
|
1022
|
+
return h}
|
|
1023
|
+
function color(n){if(!colorMap[n]){colorMap[n]=COLORS[ci%COLORS.length];ci++}return colorMap[n]}
|
|
1024
|
+
var chips='';
|
|
1025
|
+
var seen={};
|
|
1026
|
+
for(var a=0;a<data.length;a++){var f=data[a].from;if(!seen[f]){seen[f]=true;var c=color(f);chips+='<div class="agent-chip"><div class="dot" style="background:'+c+'">'+f.charAt(0).toUpperCase()+'</div>'+esc(f)+'</div>'}}
|
|
1027
|
+
document.getElementById('agent-chips').innerHTML=chips;
|
|
1028
|
+
var html='';var lastDate='';
|
|
1029
|
+
for(var i=0;i<data.length;i++){var m=data[i];var c=color(m.from);
|
|
1030
|
+
var msgDate=new Date(m.timestamp).toLocaleDateString();
|
|
1031
|
+
if(msgDate!==lastDate){var today=new Date().toLocaleDateString();var label=msgDate===today?'Today':msgDate;html+='<div class="date-sep">'+label+'</div>';lastDate=msgDate}
|
|
1032
|
+
var badges='';if(m.acked)badges+='<span class="badge badge-ack">ACK</span>';
|
|
1033
|
+
html+='<div class="msg"><div class="avatar" style="background:'+c+'">'+m.from.charAt(0).toUpperCase()+'</div><div class="msg-body"><div class="msg-header"><span class="msg-from" style="color:'+c+'">'+esc(m.from)+'</span><span class="msg-arrow">→</span><span class="msg-to">'+esc(m.to)+'</span><span class="msg-time">'+new Date(m.timestamp).toLocaleTimeString()+'</span>'+badges+'</div><div class="msg-content">'+fmt(m.content)+'</div></div></div>'}
|
|
1034
|
+
document.getElementById('messages').innerHTML=html;
|
|
1035
|
+
</script></body></html>`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Timeline API — agent activity over time for heatmap visualization
|
|
1039
|
+
function apiTimeline(query) {
|
|
1040
|
+
const projectPath = query.get('project') || null;
|
|
1041
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
1042
|
+
if (history.length === 0) return { agents: {}, duration_seconds: 0 };
|
|
1043
|
+
|
|
1044
|
+
const agents = {};
|
|
1045
|
+
const startTime = new Date(history[0].timestamp).getTime();
|
|
1046
|
+
const endTime = new Date(history[history.length - 1].timestamp).getTime();
|
|
1047
|
+
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
|
1048
|
+
|
|
1049
|
+
// Build activity windows per agent — each message marks a 30s "active" window
|
|
1050
|
+
for (const m of history) {
|
|
1051
|
+
if (!agents[m.from]) {
|
|
1052
|
+
agents[m.from] = { message_count: 0, active_seconds: 0, gaps: [], timestamps: [] };
|
|
1053
|
+
}
|
|
1054
|
+
agents[m.from].message_count++;
|
|
1055
|
+
agents[m.from].timestamps.push(m.timestamp);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Calculate activity percentage and response gaps
|
|
1059
|
+
for (const [name, data] of Object.entries(agents)) {
|
|
1060
|
+
const ts = data.timestamps.map(t => new Date(t).getTime());
|
|
1061
|
+
let activeSeconds = 0;
|
|
1062
|
+
for (let i = 0; i < ts.length; i++) {
|
|
1063
|
+
activeSeconds += 30; // each message = ~30s of activity
|
|
1064
|
+
if (i > 0) {
|
|
1065
|
+
const gap = Math.floor((ts[i] - ts[i - 1]) / 1000);
|
|
1066
|
+
if (gap > 60) {
|
|
1067
|
+
data.gaps.push({ after_message: i, gap_seconds: gap });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
data.active_seconds = Math.min(activeSeconds, durationSeconds || 1);
|
|
1072
|
+
data.activity_pct = durationSeconds > 0 ? Math.round((data.active_seconds / durationSeconds) * 100) : 100;
|
|
1073
|
+
delete data.timestamps; // don't send raw timestamps
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
agents,
|
|
1078
|
+
duration_seconds: durationSeconds,
|
|
1079
|
+
start_time: history[0].timestamp,
|
|
1080
|
+
end_time: history[history.length - 1].timestamp,
|
|
1081
|
+
total_messages: history.length,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Tasks API
|
|
1086
|
+
function apiTasks(query) {
|
|
1087
|
+
const projectPath = query.get('project') || null;
|
|
1088
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
1089
|
+
if (!fs.existsSync(tasksFile)) return [];
|
|
1090
|
+
try { return JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch { return []; }
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function apiUpdateTask(body, query) {
|
|
1094
|
+
const projectPath = query.get('project') || null;
|
|
1095
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
1096
|
+
if (!body.task_id || !body.status) return { error: 'Missing task_id or status' };
|
|
1097
|
+
|
|
1098
|
+
let tasks = [];
|
|
1099
|
+
if (fs.existsSync(tasksFile)) {
|
|
1100
|
+
try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const task = tasks.find(t => t.id === body.task_id);
|
|
1104
|
+
if (!task) return { error: 'Task not found' };
|
|
1105
|
+
|
|
1106
|
+
const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
|
|
1107
|
+
if (!validStatuses.includes(body.status)) return { error: 'Invalid status. Must be: ' + validStatuses.join(', ') };
|
|
1108
|
+
task.status = body.status;
|
|
1109
|
+
task.updated_at = new Date().toISOString();
|
|
1110
|
+
if (body.notes) {
|
|
1111
|
+
if (!Array.isArray(task.notes)) task.notes = [];
|
|
1112
|
+
task.notes.push({ by: 'Dashboard', text: body.notes, at: new Date().toISOString() });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
|
|
1116
|
+
return { success: true, task_id: task.id, status: task.status };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Rules API
|
|
1120
|
+
function apiRules(query) {
|
|
1121
|
+
const projectPath = query.get('project') || null;
|
|
1122
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1123
|
+
if (!fs.existsSync(rulesFile)) return [];
|
|
1124
|
+
try { return JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch { return []; }
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function apiAddRule(body, query) {
|
|
1128
|
+
const projectPath = query.get('project') || null;
|
|
1129
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1130
|
+
if (!body.text || !body.text.trim()) return { error: 'Rule text is required' };
|
|
1131
|
+
|
|
1132
|
+
const crypto = require('crypto');
|
|
1133
|
+
let rules = [];
|
|
1134
|
+
if (fs.existsSync(rulesFile)) {
|
|
1135
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const rule = {
|
|
1139
|
+
id: 'rule_' + crypto.randomBytes(6).toString('hex'),
|
|
1140
|
+
text: body.text.trim(),
|
|
1141
|
+
category: body.category || 'general',
|
|
1142
|
+
priority: body.priority || 'normal',
|
|
1143
|
+
created_by: body.created_by || 'Dashboard',
|
|
1144
|
+
created_at: new Date().toISOString(),
|
|
1145
|
+
active: true
|
|
1146
|
+
};
|
|
1147
|
+
rules.push(rule);
|
|
1148
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1149
|
+
return { success: true, rule };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function apiUpdateRule(body, query) {
|
|
1153
|
+
const projectPath = query.get('project') || null;
|
|
1154
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1155
|
+
if (!body.rule_id) return { error: 'Missing rule_id' };
|
|
1156
|
+
|
|
1157
|
+
let rules = [];
|
|
1158
|
+
if (fs.existsSync(rulesFile)) {
|
|
1159
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const rule = rules.find(r => r.id === body.rule_id);
|
|
1163
|
+
if (!rule) return { error: 'Rule not found' };
|
|
1164
|
+
|
|
1165
|
+
if (body.text !== undefined) rule.text = body.text.trim();
|
|
1166
|
+
if (body.category !== undefined) rule.category = body.category;
|
|
1167
|
+
if (body.priority !== undefined) rule.priority = body.priority;
|
|
1168
|
+
if (body.active !== undefined) rule.active = body.active;
|
|
1169
|
+
rule.updated_at = new Date().toISOString();
|
|
1170
|
+
|
|
1171
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1172
|
+
return { success: true, rule };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function apiDeleteRule(body, query) {
|
|
1176
|
+
const projectPath = query.get('project') || null;
|
|
1177
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1178
|
+
if (!body.rule_id) return { error: 'Missing rule_id' };
|
|
1179
|
+
|
|
1180
|
+
let rules = [];
|
|
1181
|
+
if (fs.existsSync(rulesFile)) {
|
|
1182
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const idx = rules.findIndex(r => r.id === body.rule_id);
|
|
1186
|
+
if (idx === -1) return { error: 'Rule not found' };
|
|
1187
|
+
rules.splice(idx, 1);
|
|
1188
|
+
|
|
1189
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1190
|
+
return { success: true };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Auto-discover .neohive directories nearby
|
|
1194
|
+
function apiDiscover() {
|
|
1195
|
+
const found = [];
|
|
1196
|
+
const checked = new Set();
|
|
1197
|
+
const existing = new Set(getProjects().map(p => p.path));
|
|
1198
|
+
|
|
1199
|
+
function scanDir(dir, depth, maxDepth) {
|
|
1200
|
+
maxDepth = maxDepth || 3;
|
|
1201
|
+
if (depth > maxDepth || checked.has(dir)) return;
|
|
1202
|
+
checked.add(dir);
|
|
1203
|
+
try {
|
|
1204
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1205
|
+
for (const entry of entries) {
|
|
1206
|
+
if (!entry.isDirectory()) continue;
|
|
1207
|
+
if (entry.name.startsWith('.') && entry.name !== '.neohive') continue;
|
|
1208
|
+
if (entry.name === 'node_modules') continue;
|
|
1209
|
+
const fullPath = path.join(dir, entry.name);
|
|
1210
|
+
if (entry.name === '.neohive' && hasDataFiles(fullPath)) {
|
|
1211
|
+
const projectPath = dir;
|
|
1212
|
+
if (!existing.has(projectPath)) {
|
|
1213
|
+
found.push({ name: path.basename(projectPath), path: projectPath, dataDir: fullPath });
|
|
1214
|
+
}
|
|
1215
|
+
} else if (depth < maxDepth) {
|
|
1216
|
+
scanDir(fullPath, depth + 1, maxDepth);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} catch {}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Scan from cwd, parent, home, Desktop, and common project locations
|
|
1223
|
+
const cwd = process.cwd();
|
|
1224
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1225
|
+
scanDir(cwd, 0);
|
|
1226
|
+
scanDir(path.dirname(cwd), 1);
|
|
1227
|
+
if (home) {
|
|
1228
|
+
scanDir(home, 0);
|
|
1229
|
+
scanDir(path.join(home, 'Desktop'), 0);
|
|
1230
|
+
scanDir(path.join(home, 'Documents'), 0);
|
|
1231
|
+
scanDir(path.join(home, 'Projects'), 0);
|
|
1232
|
+
scanDir(path.join(home, 'Desktop', 'Claude Projects'), 0);
|
|
1233
|
+
scanDir(path.join(home, 'Desktop', 'Projects'), 0);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return found;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// --- Agent Launcher ---
|
|
1240
|
+
|
|
1241
|
+
function ensureMCPConfig(cli, serverPath, projectDir) {
|
|
1242
|
+
const abDir = path.join(projectDir, '.neohive').replace(/\\/g, '/');
|
|
1243
|
+
if (cli === 'claude') {
|
|
1244
|
+
const mcpConfigPath = path.join(projectDir, '.mcp.json');
|
|
1245
|
+
let mcpConfig = { mcpServers: {} };
|
|
1246
|
+
if (fs.existsSync(mcpConfigPath)) {
|
|
1247
|
+
try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
|
|
1248
|
+
}
|
|
1249
|
+
if (!mcpConfig.mcpServers['neohive']) {
|
|
1250
|
+
mcpConfig.mcpServers['neohive'] = { command: 'node', args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
|
|
1251
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
|
|
1252
|
+
}
|
|
1253
|
+
} else if (cli === 'gemini') {
|
|
1254
|
+
const geminiDir = path.join(projectDir, '.gemini');
|
|
1255
|
+
const settingsPath = path.join(geminiDir, 'settings.json');
|
|
1256
|
+
if (!fs.existsSync(geminiDir)) fs.mkdirSync(geminiDir, { recursive: true });
|
|
1257
|
+
let settings = { mcpServers: {} };
|
|
1258
|
+
if (fs.existsSync(settingsPath)) {
|
|
1259
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); if (!settings.mcpServers) settings.mcpServers = {}; } catch {}
|
|
1260
|
+
}
|
|
1261
|
+
if (!settings.mcpServers['neohive']) {
|
|
1262
|
+
settings.mcpServers['neohive'] = { command: 'node', args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
|
|
1263
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1264
|
+
}
|
|
1265
|
+
} else if (cli === 'codex') {
|
|
1266
|
+
const codexDir = path.join(projectDir, '.codex');
|
|
1267
|
+
const configPath = path.join(codexDir, 'config.toml');
|
|
1268
|
+
if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
|
|
1269
|
+
let config = '';
|
|
1270
|
+
if (fs.existsSync(configPath)) config = fs.readFileSync(configPath, 'utf8');
|
|
1271
|
+
if (!config.includes('[mcp_servers.neohive]')) {
|
|
1272
|
+
config += `\n[mcp_servers.neohive]\ncommand = "node"\nargs = [${JSON.stringify(serverPath)}]\n\n[mcp_servers.neohive.env]\nNEOHIVE_DATA_DIR = ${JSON.stringify(abDir)}\n`;
|
|
1273
|
+
fs.writeFileSync(configPath, config);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function apiLaunchAgent(body) {
|
|
1279
|
+
const { cli, project_dir, agent_name, prompt } = body;
|
|
1280
|
+
if (!cli || !['claude', 'gemini', 'codex'].includes(cli)) {
|
|
1281
|
+
return { error: 'Invalid cli type. Must be: claude, gemini, or codex' };
|
|
1282
|
+
}
|
|
1283
|
+
if (project_dir && !validateProjectPath(project_dir)) {
|
|
1284
|
+
return { error: 'Project directory not registered. Add it via the dashboard first.' };
|
|
1285
|
+
}
|
|
1286
|
+
const projectDir = project_dir || process.cwd();
|
|
1287
|
+
if (!fs.existsSync(projectDir)) {
|
|
1288
|
+
return { error: 'Project directory does not exist: ' + projectDir };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
1292
|
+
ensureMCPConfig(cli, serverPath, projectDir);
|
|
1293
|
+
|
|
1294
|
+
const cliCommands = { claude: 'claude', gemini: 'gemini', codex: 'codex' };
|
|
1295
|
+
const cliCmd = cliCommands[cli];
|
|
1296
|
+
const safeName = (agent_name || '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
|
|
1297
|
+
const launchPrompt = prompt || (safeName ? `You are agent "${safeName}". Use the register tool to register as "${safeName}", then use listen to wait for messages.` : `Register with the neohive MCP tools and use listen to wait for messages.`);
|
|
1298
|
+
|
|
1299
|
+
// Try to launch terminal — user pastes prompt from clipboard after CLI loads
|
|
1300
|
+
if (process.platform === 'win32') {
|
|
1301
|
+
spawn('cmd', ['/c', 'start', 'cmd', '/k', cliCmd], { cwd: projectDir, shell: false, detached: true, stdio: 'ignore' });
|
|
1302
|
+
return { success: true, launched: true, cli, project_dir: projectDir, prompt: launchPrompt };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Non-Windows: return command for manual execution
|
|
1306
|
+
return {
|
|
1307
|
+
success: true, launched: false, cli, project_dir: projectDir,
|
|
1308
|
+
command: `cd "${projectDir}" && ${cliCmd}`,
|
|
1309
|
+
prompt: launchPrompt,
|
|
1310
|
+
message: 'Run the command in a terminal, then paste the prompt.'
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// --- v3.4: Message Edit ---
|
|
1315
|
+
async function apiEditMessage(body, query) {
|
|
1316
|
+
const projectPath = query.get('project') || null;
|
|
1317
|
+
const { id, content } = body;
|
|
1318
|
+
if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
|
|
1319
|
+
if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
|
|
1320
|
+
|
|
1321
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1322
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
1323
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
1324
|
+
|
|
1325
|
+
let found = false;
|
|
1326
|
+
const now = new Date().toISOString();
|
|
1327
|
+
|
|
1328
|
+
// Update in history.jsonl (locked)
|
|
1329
|
+
await withFileLock(historyFile, () => {
|
|
1330
|
+
if (fs.existsSync(historyFile)) {
|
|
1331
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/).filter(Boolean);
|
|
1332
|
+
const updated = lines.map(line => {
|
|
1333
|
+
try {
|
|
1334
|
+
const msg = JSON.parse(line);
|
|
1335
|
+
if (msg.id === id) {
|
|
1336
|
+
found = true;
|
|
1337
|
+
if (!msg.edit_history) msg.edit_history = [];
|
|
1338
|
+
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
1339
|
+
if (msg.edit_history.length > 10) msg.edit_history = msg.edit_history.slice(-10);
|
|
1340
|
+
msg.content = content;
|
|
1341
|
+
msg.edited = true;
|
|
1342
|
+
msg.edited_at = now;
|
|
1343
|
+
return JSON.stringify(msg);
|
|
1344
|
+
}
|
|
1345
|
+
return line;
|
|
1346
|
+
} catch { return line; }
|
|
1347
|
+
});
|
|
1348
|
+
if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// Also update in messages.jsonl (locked independently)
|
|
1353
|
+
if (found) {
|
|
1354
|
+
await withFileLock(messagesFile, () => {
|
|
1355
|
+
if (fs.existsSync(messagesFile)) {
|
|
1356
|
+
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
1357
|
+
if (raw) {
|
|
1358
|
+
const lines = raw.split(/\r?\n/);
|
|
1359
|
+
const updated = lines.map(line => {
|
|
1360
|
+
try {
|
|
1361
|
+
const msg = JSON.parse(line);
|
|
1362
|
+
if (msg.id === id) {
|
|
1363
|
+
msg.content = content;
|
|
1364
|
+
msg.edited = true;
|
|
1365
|
+
msg.edited_at = now;
|
|
1366
|
+
return JSON.stringify(msg);
|
|
1367
|
+
}
|
|
1368
|
+
return line;
|
|
1369
|
+
} catch { return line; }
|
|
1370
|
+
});
|
|
1371
|
+
fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (!found) return { error: 'Message not found' };
|
|
1378
|
+
return { success: true, id, edited_at: now };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// --- v3.4: Message Delete ---
|
|
1382
|
+
async function apiDeleteMessage(body, query) {
|
|
1383
|
+
const projectPath = query.get('project') || null;
|
|
1384
|
+
const { id } = body;
|
|
1385
|
+
if (!id) return { error: 'Missing "id" field' };
|
|
1386
|
+
|
|
1387
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1388
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
1389
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
1390
|
+
|
|
1391
|
+
let found = false;
|
|
1392
|
+
let msgFrom = null;
|
|
1393
|
+
|
|
1394
|
+
// Find the message and remove from history.jsonl (locked)
|
|
1395
|
+
await withFileLock(historyFile, () => {
|
|
1396
|
+
if (fs.existsSync(historyFile)) {
|
|
1397
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/);
|
|
1398
|
+
for (const line of lines) {
|
|
1399
|
+
try {
|
|
1400
|
+
const msg = JSON.parse(line);
|
|
1401
|
+
if (msg.id === id) { found = true; msgFrom = msg.from; break; }
|
|
1402
|
+
} catch {}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (found) {
|
|
1406
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
1407
|
+
if (allowed.includes(msgFrom)) {
|
|
1408
|
+
const filtered = lines.filter(line => {
|
|
1409
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1410
|
+
});
|
|
1411
|
+
fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
if (!found) return { error: 'Message not found' };
|
|
1418
|
+
|
|
1419
|
+
// Only allow deleting dashboard-injected or system messages
|
|
1420
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
1421
|
+
if (!allowed.includes(msgFrom)) {
|
|
1422
|
+
return { error: 'Can only delete messages sent from Dashboard or system' };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Remove from messages.jsonl (locked independently)
|
|
1426
|
+
await withFileLock(messagesFile, () => {
|
|
1427
|
+
if (fs.existsSync(messagesFile)) {
|
|
1428
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(/\r?\n/);
|
|
1429
|
+
const filtered = lines.filter(line => {
|
|
1430
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1431
|
+
});
|
|
1432
|
+
fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
return { success: true, id };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// --- v3.4: Conversation Templates ---
|
|
1440
|
+
function apiGetConversationTemplates() {
|
|
1441
|
+
const templatesDir = path.join(__dirname, 'conversation-templates');
|
|
1442
|
+
if (!fs.existsSync(templatesDir)) {
|
|
1443
|
+
// Return built-in templates
|
|
1444
|
+
return getBuiltInConversationTemplates();
|
|
1445
|
+
}
|
|
1446
|
+
const custom = fs.readdirSync(templatesDir)
|
|
1447
|
+
.filter(f => f.endsWith('.json'))
|
|
1448
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
|
|
1449
|
+
.filter(Boolean);
|
|
1450
|
+
return [...getBuiltInConversationTemplates(), ...custom];
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function getBuiltInConversationTemplates() {
|
|
1454
|
+
return [
|
|
1455
|
+
{
|
|
1456
|
+
id: 'code-review',
|
|
1457
|
+
name: 'Code Review Pipeline',
|
|
1458
|
+
description: 'Developer writes code, Reviewer checks it, Tester validates',
|
|
1459
|
+
agents: [
|
|
1460
|
+
{ name: 'Developer', role: 'Developer', prompt: 'You are a developer. Write code as instructed. After completing, send your code to Reviewer for review.' },
|
|
1461
|
+
{ name: 'Reviewer', role: 'Code Reviewer', prompt: 'You are a code reviewer. Wait for code from Developer. Review it for bugs, style, and best practices. Send feedback back to Developer or approve and forward to Tester.' },
|
|
1462
|
+
{ name: 'Tester', role: 'QA Tester', prompt: 'You are a QA tester. Wait for approved code from Reviewer. Write and run tests. Report results back to the team.' }
|
|
1463
|
+
],
|
|
1464
|
+
workflow: { name: 'Code Review', steps: ['Write Code', 'Review', 'Test', 'Approve'] }
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
id: 'debug-squad',
|
|
1468
|
+
name: 'Debug Squad',
|
|
1469
|
+
description: 'Investigator finds the bug, Fixer patches it, Verifier confirms the fix',
|
|
1470
|
+
agents: [
|
|
1471
|
+
{ name: 'Investigator', role: 'Bug Investigator', prompt: 'You investigate bugs. Analyze error logs, trace code paths, and identify root causes. Send findings to Fixer.' },
|
|
1472
|
+
{ name: 'Fixer', role: 'Bug Fixer', prompt: 'You fix bugs. Wait for findings from Investigator. Implement fixes and send to Verifier for confirmation.' },
|
|
1473
|
+
{ name: 'Verifier', role: 'Fix Verifier', prompt: 'You verify bug fixes. Wait for patches from Fixer. Test the fix and confirm resolution or send back for more work.' }
|
|
1474
|
+
],
|
|
1475
|
+
workflow: { name: 'Bug Fix', steps: ['Investigate', 'Fix', 'Verify', 'Close'] }
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
id: 'feature-build',
|
|
1479
|
+
name: 'Feature Development',
|
|
1480
|
+
description: 'Architect designs, Builder implements, Reviewer approves',
|
|
1481
|
+
agents: [
|
|
1482
|
+
{ name: 'Architect', role: 'Software Architect', prompt: 'You are a software architect. Design the feature architecture, define interfaces, and create the implementation plan. Send the plan to Builder.' },
|
|
1483
|
+
{ name: 'Builder', role: 'Developer', prompt: 'You are a developer. Wait for architecture plans from Architect. Implement the feature following the design. Send completed code to Reviewer.' },
|
|
1484
|
+
{ name: 'Reviewer', role: 'Senior Reviewer', prompt: 'You are a senior reviewer. Review implementations from Builder against the architecture from Architect. Approve or request changes.' }
|
|
1485
|
+
],
|
|
1486
|
+
workflow: { name: 'Feature Dev', steps: ['Design', 'Implement', 'Review', 'Ship'] }
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
id: 'research-write',
|
|
1490
|
+
name: 'Research & Write',
|
|
1491
|
+
description: 'Researcher gathers info, Writer creates content, Editor polishes',
|
|
1492
|
+
agents: [
|
|
1493
|
+
{ name: 'Researcher', role: 'Researcher', prompt: 'You are a researcher. Gather information on the given topic. Organize findings and send a research brief to Writer.' },
|
|
1494
|
+
{ name: 'Writer', role: 'Writer', prompt: 'You are a writer. Wait for research from Researcher. Write clear, well-structured content based on the findings. Send to Editor.' },
|
|
1495
|
+
{ name: 'Editor', role: 'Editor', prompt: 'You are an editor. Review and polish content from Writer. Check for clarity, accuracy, and style. Send back final version or request revisions.' }
|
|
1496
|
+
],
|
|
1497
|
+
workflow: { name: 'Content Pipeline', steps: ['Research', 'Draft', 'Edit', 'Publish'] }
|
|
1498
|
+
}
|
|
1499
|
+
];
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function apiLaunchConversationTemplate(body, query) {
|
|
1503
|
+
const projectPath = query.get('project') || null;
|
|
1504
|
+
const { template_id } = body;
|
|
1505
|
+
if (!template_id) return { error: 'Missing template_id' };
|
|
1506
|
+
|
|
1507
|
+
const templates = apiGetConversationTemplates();
|
|
1508
|
+
const template = templates.find(t => t.id === template_id);
|
|
1509
|
+
if (!template) return { error: 'Template not found: ' + template_id };
|
|
1510
|
+
|
|
1511
|
+
// Return the template config for the frontend to display launch instructions
|
|
1512
|
+
return {
|
|
1513
|
+
success: true,
|
|
1514
|
+
template,
|
|
1515
|
+
instructions: template.agents.map(a => ({
|
|
1516
|
+
agent_name: a.name,
|
|
1517
|
+
role: a.role,
|
|
1518
|
+
prompt: `You are "${a.name}" with role "${a.role}". ${a.prompt}\n\nFirst register yourself with: register(name="${a.name}"), then update_profile(role="${a.role}"). Then enter listen mode.`
|
|
1519
|
+
}))
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// --- v3.4: Agent Permissions ---
|
|
1524
|
+
function apiUpdatePermissions(body, query) {
|
|
1525
|
+
const projectPath = query.get('project') || null;
|
|
1526
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1527
|
+
const permFile = path.join(dataDir, 'permissions.json');
|
|
1528
|
+
|
|
1529
|
+
const { agent, permissions } = body;
|
|
1530
|
+
if (!agent || !permissions) return { error: 'Missing "agent" and/or "permissions" fields' };
|
|
1531
|
+
|
|
1532
|
+
let perms = {};
|
|
1533
|
+
if (fs.existsSync(permFile)) {
|
|
1534
|
+
try { perms = JSON.parse(fs.readFileSync(permFile, 'utf8')); } catch {}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// permissions: { can_read: [agents...] or "*", can_write_to: [agents...] or "*", is_admin: bool }
|
|
1538
|
+
const allowed = {};
|
|
1539
|
+
if (permissions.can_read !== undefined) allowed.can_read = permissions.can_read;
|
|
1540
|
+
if (permissions.can_write_to !== undefined) allowed.can_write_to = permissions.can_write_to;
|
|
1541
|
+
if (permissions.is_admin !== undefined) allowed.is_admin = !!permissions.is_admin;
|
|
1542
|
+
perms[agent] = {
|
|
1543
|
+
...perms[agent],
|
|
1544
|
+
...allowed,
|
|
1545
|
+
updated_at: new Date().toISOString()
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
1549
|
+
fs.writeFileSync(permFile, JSON.stringify(perms, null, 2));
|
|
1550
|
+
return { success: true, agent, permissions: perms[agent] };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// --- HTTP Server ---
|
|
1554
|
+
|
|
1555
|
+
// Load HTML at startup (re-read on each request in dev for hot-reload)
|
|
1556
|
+
let htmlContent = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1557
|
+
|
|
1558
|
+
const MAX_BODY = 1 * 1024 * 1024; // 1 MB
|
|
1559
|
+
|
|
1560
|
+
function parseBody(req) {
|
|
1561
|
+
return new Promise((resolve, reject) => {
|
|
1562
|
+
let data = '';
|
|
1563
|
+
req.on('data', chunk => {
|
|
1564
|
+
data += chunk;
|
|
1565
|
+
if (data.length > MAX_BODY) {
|
|
1566
|
+
req.destroy();
|
|
1567
|
+
reject(new Error('Request body too large'));
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
req.on('end', () => {
|
|
1571
|
+
try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON body')); }
|
|
1572
|
+
});
|
|
1573
|
+
req.on('error', reject);
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// --- Rate limiting ---
|
|
1578
|
+
const apiRateLimits = new Map();
|
|
1579
|
+
function checkRateLimit(ip, limit = 60, windowMs = 60000) {
|
|
1580
|
+
const now = Date.now();
|
|
1581
|
+
const key = ip;
|
|
1582
|
+
if (!apiRateLimits.has(key)) apiRateLimits.set(key, []);
|
|
1583
|
+
const timestamps = apiRateLimits.get(key).filter(t => now - t < windowMs);
|
|
1584
|
+
apiRateLimits.set(key, timestamps);
|
|
1585
|
+
if (timestamps.length >= limit) return false;
|
|
1586
|
+
timestamps.push(now);
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
// Periodic cleanup to prevent memory leak
|
|
1590
|
+
setInterval(() => {
|
|
1591
|
+
const now = Date.now();
|
|
1592
|
+
for (const [key, timestamps] of apiRateLimits) {
|
|
1593
|
+
const filtered = timestamps.filter(t => now - t < 60000);
|
|
1594
|
+
if (filtered.length === 0) apiRateLimits.delete(key);
|
|
1595
|
+
else apiRateLimits.set(key, filtered);
|
|
1596
|
+
}
|
|
1597
|
+
}, 300000).unref(); // Clean every 5 minutes, .unref() prevents zombie process
|
|
1598
|
+
|
|
1599
|
+
const server = http.createServer(async (req, res) => {
|
|
1600
|
+
const url = new URL(req.url, 'http://localhost:' + PORT);
|
|
1601
|
+
|
|
1602
|
+
const allowedOrigin = `http://localhost:${PORT}`;
|
|
1603
|
+
const reqOrigin = req.headers.origin;
|
|
1604
|
+
const lanIP = getLanIP();
|
|
1605
|
+
const lanOrigin = lanIP ? `http://${lanIP}:${PORT}` : null;
|
|
1606
|
+
const trustedOrigins = [allowedOrigin, `http://127.0.0.1:${PORT}`];
|
|
1607
|
+
if (LAN_MODE && lanOrigin) trustedOrigins.push(lanOrigin);
|
|
1608
|
+
if (reqOrigin && trustedOrigins.includes(reqOrigin)) {
|
|
1609
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
1610
|
+
}
|
|
1611
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
1612
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
|
|
1613
|
+
|
|
1614
|
+
if (req.method === 'OPTIONS') {
|
|
1615
|
+
res.writeHead(204);
|
|
1616
|
+
res.end();
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// LAN auth token — required for non-localhost requests when LAN mode is active
|
|
1621
|
+
if (LAN_MODE) {
|
|
1622
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1623
|
+
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
|
|
1624
|
+
if (!isLocalhost) {
|
|
1625
|
+
const tokenFromQuery = url.searchParams.get('token');
|
|
1626
|
+
const tokenFromHeader = req.headers['x-ltt-token'];
|
|
1627
|
+
const providedToken = tokenFromHeader || tokenFromQuery;
|
|
1628
|
+
const crypto = require('crypto');
|
|
1629
|
+
if (!providedToken || providedToken.length !== LAN_TOKEN.length || !crypto.timingSafeEqual(Buffer.from(providedToken), Buffer.from(LAN_TOKEN))) {
|
|
1630
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1631
|
+
res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// CSRF + DNS rebinding protection: validate Host, Origin, and custom header on mutating requests
|
|
1638
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
1639
|
+
// Check Host header to block DNS rebinding attacks
|
|
1640
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1641
|
+
const validHosts = ['localhost', '127.0.0.1'];
|
|
1642
|
+
if (LAN_MODE && getLanIP()) validHosts.push(getLanIP());
|
|
1643
|
+
if (!validHosts.includes(host)) {
|
|
1644
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1645
|
+
res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
// Require custom header — browsers block cross-origin custom headers without preflight,
|
|
1649
|
+
// which our CORS policy won't approve for foreign origins. This closes the no-Origin gap.
|
|
1650
|
+
if (!req.headers['x-ltt-request']) {
|
|
1651
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1652
|
+
res.end(JSON.stringify({ error: 'Forbidden: missing X-LTT-Request header' }));
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
// Check Origin header to block cross-site requests
|
|
1656
|
+
// Empty origin is NOT trusted — requires at least the custom header (checked above)
|
|
1657
|
+
const origin = req.headers.origin || '';
|
|
1658
|
+
const referer = req.headers.referer || '';
|
|
1659
|
+
const source = origin || referer;
|
|
1660
|
+
if (!source) {
|
|
1661
|
+
// No origin/referer — non-browser client (curl, scripts, etc.)
|
|
1662
|
+
// Allow local CLI tools but block non-local requests without origin
|
|
1663
|
+
const reqHost = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1664
|
+
if (reqHost !== 'localhost' && reqHost !== '127.0.0.1' && !reqHost.startsWith('192.168.') && !reqHost.startsWith('10.')) {
|
|
1665
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1666
|
+
res.end(JSON.stringify({ error: 'Forbidden: non-local request without origin' }));
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
const allowedSources = [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`];
|
|
1671
|
+
if (LAN_MODE && getLanIP()) allowedSources.push(`http://${getLanIP()}:${PORT}`);
|
|
1672
|
+
let sourceOrigin = '';
|
|
1673
|
+
try { sourceOrigin = source ? new URL(source).origin : ''; } catch { sourceOrigin = ''; }
|
|
1674
|
+
const isLocal = allowedSources.includes(sourceOrigin);
|
|
1675
|
+
const isLan = isLocal;
|
|
1676
|
+
if (source && !isLocal && !isLan) {
|
|
1677
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1678
|
+
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Rate limit API endpoints (only for non-localhost in LAN mode)
|
|
1684
|
+
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
1685
|
+
const isLocalhost = clientIP === '127.0.0.1' || clientIP === '::1' || clientIP === '::ffff:127.0.0.1';
|
|
1686
|
+
if (url.pathname.startsWith('/api/') && !isLocalhost && !checkRateLimit(clientIP, 300, 60000)) {
|
|
1687
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1688
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded. Try again later.' }));
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
try {
|
|
1693
|
+
// Validate project parameter on all API endpoints
|
|
1694
|
+
const projectParam = url.searchParams.get('project');
|
|
1695
|
+
if (projectParam && url.pathname.startsWith('/api/') && !validateProjectPath(projectParam)) {
|
|
1696
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1697
|
+
res.end(JSON.stringify({ error: 'Project path not registered. Add it via /api/projects first.' }));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Serve logo image
|
|
1702
|
+
if (url.pathname === '/logo.png') {
|
|
1703
|
+
if (fs.existsSync(LOGO_FILE)) {
|
|
1704
|
+
const logo = fs.readFileSync(LOGO_FILE);
|
|
1705
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
|
|
1706
|
+
res.end(logo);
|
|
1707
|
+
} else {
|
|
1708
|
+
res.writeHead(404);
|
|
1709
|
+
res.end('Logo not found');
|
|
1710
|
+
}
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Serve dashboard HTML (always re-read for hot reload)
|
|
1715
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
1716
|
+
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1717
|
+
res.writeHead(200, {
|
|
1718
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1719
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
|
|
1720
|
+
'X-Frame-Options': 'DENY',
|
|
1721
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1722
|
+
'Referrer-Policy': 'no-referrer',
|
|
1723
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1724
|
+
'Pragma': 'no-cache',
|
|
1725
|
+
'Expires': '0'
|
|
1726
|
+
});
|
|
1727
|
+
res.end(html);
|
|
1728
|
+
}
|
|
1729
|
+
// Existing APIs (now with ?project= param support)
|
|
1730
|
+
else if (url.pathname === '/api/history' && req.method === 'GET') {
|
|
1731
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1732
|
+
res.end(JSON.stringify(apiHistory(url.searchParams)));
|
|
1733
|
+
}
|
|
1734
|
+
else if (url.pathname === '/api/agents' && req.method === 'GET') {
|
|
1735
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1736
|
+
res.end(JSON.stringify(apiAgents(url.searchParams)));
|
|
1737
|
+
}
|
|
1738
|
+
else if (url.pathname === '/api/channels' && req.method === 'GET') {
|
|
1739
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1740
|
+
res.end(JSON.stringify(apiChannels(url.searchParams)));
|
|
1741
|
+
}
|
|
1742
|
+
else if (url.pathname === '/api/decisions' && req.method === 'GET') {
|
|
1743
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1744
|
+
const decisions = readJson(filePath('decisions.json', projectPath));
|
|
1745
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1746
|
+
res.end(JSON.stringify(decisions || []));
|
|
1747
|
+
}
|
|
1748
|
+
else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
|
|
1749
|
+
const body = await parseBody(req);
|
|
1750
|
+
if (!body.name) {
|
|
1751
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1752
|
+
res.end(JSON.stringify({ error: 'Missing agent name' }));
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const agentName = body.name;
|
|
1756
|
+
const dataDir = resolveDataDir(url.searchParams.get('project'));
|
|
1757
|
+
const agentsFile = path.join(dataDir, 'agents.json');
|
|
1758
|
+
const profilesFile = path.join(dataDir, 'profiles.json');
|
|
1759
|
+
await withFileLock(agentsFile, () => {
|
|
1760
|
+
// Remove from agents.json
|
|
1761
|
+
if (fs.existsSync(agentsFile)) {
|
|
1762
|
+
const agents = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
|
|
1763
|
+
if (!agents[agentName]) {
|
|
1764
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1765
|
+
res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
delete agents[agentName];
|
|
1769
|
+
fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2));
|
|
1770
|
+
}
|
|
1771
|
+
// Remove from profiles.json
|
|
1772
|
+
if (fs.existsSync(profilesFile)) {
|
|
1773
|
+
const profiles = JSON.parse(fs.readFileSync(profilesFile, 'utf8'));
|
|
1774
|
+
delete profiles[agentName];
|
|
1775
|
+
fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
|
|
1776
|
+
}
|
|
1777
|
+
// Remove consumed file
|
|
1778
|
+
const consumedFile = path.join(dataDir, 'consumed-' + agentName + '.json');
|
|
1779
|
+
if (fs.existsSync(consumedFile)) fs.unlinkSync(consumedFile);
|
|
1780
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1781
|
+
res.end(JSON.stringify({ success: true, removed: agentName }));
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
// Respawn prompt generator — creates copy-paste prompt to revive a dead agent
|
|
1785
|
+
else if (url.pathname.startsWith('/api/agents/') && url.pathname.endsWith('/respawn-prompt') && req.method === 'GET') {
|
|
1786
|
+
const agentName = decodeURIComponent(url.pathname.split('/')[3]);
|
|
1787
|
+
// Validate agent name (prevent path traversal)
|
|
1788
|
+
if (!agentName || /[^a-zA-Z0-9_-]/.test(agentName) || agentName.length > 20) {
|
|
1789
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1790
|
+
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1794
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1795
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
1796
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
1797
|
+
const tasks = readJson(filePath('tasks.json', projectPath));
|
|
1798
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
1799
|
+
|
|
1800
|
+
if (!agents[agentName]) {
|
|
1801
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1802
|
+
res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Gather recovery snapshot if exists
|
|
1807
|
+
const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
|
|
1808
|
+
const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
|
|
1809
|
+
|
|
1810
|
+
// Gather profile
|
|
1811
|
+
const profile = profiles[agentName] || {};
|
|
1812
|
+
|
|
1813
|
+
// Gather active tasks assigned to this agent
|
|
1814
|
+
const taskList = Array.isArray(tasks) ? tasks : [];
|
|
1815
|
+
const activeTasks = taskList.filter(t => t.assignee === agentName && (t.status === 'in_progress' || t.status === 'pending'));
|
|
1816
|
+
const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
|
|
1817
|
+
|
|
1818
|
+
// Gather recent history context (last 15 messages)
|
|
1819
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
1820
|
+
const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
|
|
1821
|
+
|
|
1822
|
+
// Gather who's online
|
|
1823
|
+
const onlineAgents = Object.entries(agents)
|
|
1824
|
+
.filter(([n, a]) => isPidAlive(a.pid, a.last_activity) && n !== agentName)
|
|
1825
|
+
.map(([n]) => n);
|
|
1826
|
+
|
|
1827
|
+
// Gather workspace status
|
|
1828
|
+
let workspaceStatus = '';
|
|
1829
|
+
try {
|
|
1830
|
+
const wsPath = path.join(dataDir, 'workspaces', agentName + '.json');
|
|
1831
|
+
if (fs.existsSync(wsPath)) {
|
|
1832
|
+
const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
|
|
1833
|
+
if (ws._status) workspaceStatus = ws._status;
|
|
1834
|
+
}
|
|
1835
|
+
} catch {}
|
|
1836
|
+
|
|
1837
|
+
// Build the respawn prompt
|
|
1838
|
+
const mode = config.conversation_mode || 'group';
|
|
1839
|
+
let prompt = `You are resuming as agent "${agentName}" in a multi-agent team using Neohive (MCP agent bridge).\n\n`;
|
|
1840
|
+
|
|
1841
|
+
if (profile.role) prompt += `**Your role:** ${profile.role}\n`;
|
|
1842
|
+
if (profile.bio) prompt += `**Your bio:** ${profile.bio}\n`;
|
|
1843
|
+
prompt += '\n';
|
|
1844
|
+
|
|
1845
|
+
prompt += `**Conversation mode:** ${mode}\n`;
|
|
1846
|
+
prompt += `**Agents currently online:** ${onlineAgents.length > 0 ? onlineAgents.join(', ') : 'none'}\n\n`;
|
|
1847
|
+
|
|
1848
|
+
if (activeTasks.length > 0) {
|
|
1849
|
+
prompt += `**Your active tasks:**\n`;
|
|
1850
|
+
for (const t of activeTasks) {
|
|
1851
|
+
prompt += `- [${t.status}] ${t.title}${t.description ? ' — ' + t.description.substring(0, 200) : ''}\n`;
|
|
1852
|
+
}
|
|
1853
|
+
prompt += '\n';
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
if (completedTasks.length > 0) {
|
|
1857
|
+
prompt += `**Tasks you completed before disconnect:**\n`;
|
|
1858
|
+
for (const t of completedTasks) {
|
|
1859
|
+
prompt += `- ${t.title}\n`;
|
|
1860
|
+
}
|
|
1861
|
+
prompt += '\n';
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (recovery) {
|
|
1865
|
+
if (recovery.locked_files && recovery.locked_files.length > 0) {
|
|
1866
|
+
prompt += `**Files you had locked:** ${recovery.locked_files.join(', ')} — unlock these or continue editing them.\n\n`;
|
|
1867
|
+
}
|
|
1868
|
+
if (recovery.channels && recovery.channels.length > 0) {
|
|
1869
|
+
prompt += `**Channels you were in:** ${recovery.channels.join(', ')}\n\n`;
|
|
1870
|
+
}
|
|
1871
|
+
if (recovery.decisions_made && recovery.decisions_made.length > 0) {
|
|
1872
|
+
prompt += `**Decisions you made:**\n`;
|
|
1873
|
+
for (const d of recovery.decisions_made) {
|
|
1874
|
+
prompt += `- ${d.decision}${d.reasoning ? ' (reason: ' + d.reasoning + ')' : ''}\n`;
|
|
1875
|
+
}
|
|
1876
|
+
prompt += '\n';
|
|
1877
|
+
}
|
|
1878
|
+
if (recovery.last_messages_sent && recovery.last_messages_sent.length > 0) {
|
|
1879
|
+
prompt += `**Your last messages before disconnect:**\n`;
|
|
1880
|
+
for (const m of recovery.last_messages_sent) {
|
|
1881
|
+
prompt += `- [→${m.to}]: ${m.content}\n`;
|
|
1882
|
+
}
|
|
1883
|
+
prompt += '\n';
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (workspaceStatus) {
|
|
1888
|
+
prompt += `**Your last status:** ${workspaceStatus}\n\n`;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
prompt += `**Recent team conversation:**\n${recentHistory}\n\n`;
|
|
1892
|
+
|
|
1893
|
+
prompt += `**Instructions:**\n`;
|
|
1894
|
+
prompt += `1. Register as "${agentName}" using the register tool\n`;
|
|
1895
|
+
prompt += `2. Call get_briefing() for full project context\n`;
|
|
1896
|
+
prompt += `3. Call listen_group() to rejoin the conversation\n`;
|
|
1897
|
+
prompt += `4. Announce you're back and pick up your active tasks\n`;
|
|
1898
|
+
prompt += `5. Stay in listen_group() loop — never stop listening\n`;
|
|
1899
|
+
|
|
1900
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1901
|
+
res.end(JSON.stringify({
|
|
1902
|
+
agent: agentName,
|
|
1903
|
+
status: isPidAlive(agents[agentName].pid, agents[agentName].last_activity) ? 'alive' : 'dead',
|
|
1904
|
+
prompt,
|
|
1905
|
+
prompt_length: prompt.length,
|
|
1906
|
+
has_recovery: !!recovery,
|
|
1907
|
+
active_tasks: activeTasks.length,
|
|
1908
|
+
online_agents: onlineAgents,
|
|
1909
|
+
}));
|
|
1910
|
+
}
|
|
1911
|
+
else if (url.pathname === '/api/status' && req.method === 'GET') {
|
|
1912
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1913
|
+
res.end(JSON.stringify(apiStatus(url.searchParams)));
|
|
1914
|
+
}
|
|
1915
|
+
else if (url.pathname === '/api/stats' && req.method === 'GET') {
|
|
1916
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1917
|
+
res.end(JSON.stringify(apiStats(url.searchParams)));
|
|
1918
|
+
}
|
|
1919
|
+
else if (url.pathname === '/api/reset' && req.method === 'POST') {
|
|
1920
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1921
|
+
if (!body.confirm) {
|
|
1922
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1923
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1927
|
+
res.end(JSON.stringify(apiReset(url.searchParams)));
|
|
1928
|
+
}
|
|
1929
|
+
else if (url.pathname === '/api/clear-messages' && req.method === 'POST') {
|
|
1930
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1931
|
+
if (!body.confirm) {
|
|
1932
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1933
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1937
|
+
res.end(JSON.stringify(apiClearMessages(url.searchParams)));
|
|
1938
|
+
}
|
|
1939
|
+
else if (url.pathname === '/api/new-conversation' && req.method === 'POST') {
|
|
1940
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1941
|
+
if (!body.confirm) {
|
|
1942
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1943
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1947
|
+
res.end(JSON.stringify(apiNewConversation(url.searchParams)));
|
|
1948
|
+
}
|
|
1949
|
+
else if (url.pathname === '/api/conversations' && req.method === 'GET') {
|
|
1950
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1951
|
+
res.end(JSON.stringify(apiListConversations(url.searchParams)));
|
|
1952
|
+
}
|
|
1953
|
+
else if (url.pathname === '/api/load-conversation' && req.method === 'POST') {
|
|
1954
|
+
const result = apiLoadConversation(url.searchParams);
|
|
1955
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1956
|
+
res.end(JSON.stringify(result));
|
|
1957
|
+
}
|
|
1958
|
+
// Message injection
|
|
1959
|
+
else if (url.pathname === '/api/inject' && req.method === 'POST') {
|
|
1960
|
+
const body = await parseBody(req);
|
|
1961
|
+
const result = apiInjectMessage(body, url.searchParams);
|
|
1962
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1963
|
+
res.end(JSON.stringify(result));
|
|
1964
|
+
}
|
|
1965
|
+
// Multi-project management
|
|
1966
|
+
else if (url.pathname === '/api/projects' && req.method === 'GET') {
|
|
1967
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1968
|
+
res.end(JSON.stringify(apiProjects()));
|
|
1969
|
+
}
|
|
1970
|
+
else if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
1971
|
+
const body = await parseBody(req);
|
|
1972
|
+
const result = apiAddProject(body);
|
|
1973
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1974
|
+
res.end(JSON.stringify(result));
|
|
1975
|
+
}
|
|
1976
|
+
else if (url.pathname === '/api/timeline' && req.method === 'GET') {
|
|
1977
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1978
|
+
res.end(JSON.stringify(apiTimeline(url.searchParams)));
|
|
1979
|
+
}
|
|
1980
|
+
else if (url.pathname === '/api/tasks' && req.method === 'GET') {
|
|
1981
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1982
|
+
res.end(JSON.stringify(apiTasks(url.searchParams)));
|
|
1983
|
+
}
|
|
1984
|
+
else if (url.pathname === '/api/tasks' && req.method === 'POST') {
|
|
1985
|
+
const body = await parseBody(req);
|
|
1986
|
+
const result = apiUpdateTask(body, url.searchParams);
|
|
1987
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1988
|
+
res.end(JSON.stringify(result));
|
|
1989
|
+
}
|
|
1990
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
1991
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1992
|
+
res.end(JSON.stringify(apiRules(url.searchParams)));
|
|
1993
|
+
}
|
|
1994
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
1995
|
+
const body = await parseBody(req);
|
|
1996
|
+
const action = body.action || 'add';
|
|
1997
|
+
let result;
|
|
1998
|
+
if (action === 'add') result = apiAddRule(body, url.searchParams);
|
|
1999
|
+
else if (action === 'update') result = apiUpdateRule(body, url.searchParams);
|
|
2000
|
+
else if (action === 'delete') result = apiDeleteRule(body, url.searchParams);
|
|
2001
|
+
else result = { error: 'Unknown action. Use: add, update, delete' };
|
|
2002
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2003
|
+
res.end(JSON.stringify(result));
|
|
2004
|
+
}
|
|
2005
|
+
else if (url.pathname === '/api/search' && req.method === 'GET') {
|
|
2006
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2007
|
+
const query = (url.searchParams.get('q') || '').trim();
|
|
2008
|
+
const from = url.searchParams.get('from') || null;
|
|
2009
|
+
const limit = Math.min(Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)), 100);
|
|
2010
|
+
if (query.length < 2) {
|
|
2011
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2012
|
+
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
// Search general history + all channel histories
|
|
2016
|
+
let allHistory = readJsonl(filePath('history.jsonl', projectPath));
|
|
2017
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2018
|
+
try {
|
|
2019
|
+
const files = fs.readdirSync(dataDir);
|
|
2020
|
+
for (const f of files) {
|
|
2021
|
+
if (f.startsWith('channel-') && f.endsWith('-history.jsonl')) {
|
|
2022
|
+
allHistory = allHistory.concat(readJsonl(path.join(dataDir, f)));
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
} catch {}
|
|
2026
|
+
const queryLower = query.toLowerCase();
|
|
2027
|
+
const results = [];
|
|
2028
|
+
for (let i = allHistory.length - 1; i >= 0 && results.length < limit; i--) {
|
|
2029
|
+
const m = allHistory[i];
|
|
2030
|
+
if (from && m.from !== from) continue;
|
|
2031
|
+
if (m.content && m.content.toLowerCase().includes(queryLower)) {
|
|
2032
|
+
results.push({
|
|
2033
|
+
id: m.id, from: m.from, to: m.to,
|
|
2034
|
+
preview: m.content.substring(0, 200),
|
|
2035
|
+
timestamp: m.timestamp,
|
|
2036
|
+
...(m.channel && { channel: m.channel }),
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2041
|
+
res.end(JSON.stringify({ query, results_count: results.length, results }));
|
|
2042
|
+
}
|
|
2043
|
+
else if (url.pathname === '/api/export-json' && req.method === 'GET') {
|
|
2044
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2045
|
+
const history = apiHistory(url.searchParams);
|
|
2046
|
+
const agents = apiAgents(url.searchParams);
|
|
2047
|
+
const decisions = readJson(filePath('decisions.json', projectPath)) || [];
|
|
2048
|
+
const tasksRaw = readJson(filePath('tasks.json', projectPath));
|
|
2049
|
+
const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
|
|
2050
|
+
const channels = apiChannels(url.searchParams);
|
|
2051
|
+
const pkg = readJson(path.join(__dirname, 'package.json')) || {};
|
|
2052
|
+
const result = {
|
|
2053
|
+
export_version: 1,
|
|
2054
|
+
exported_at: new Date().toISOString(),
|
|
2055
|
+
project: projectPath || process.cwd(),
|
|
2056
|
+
version: pkg.version || 'unknown',
|
|
2057
|
+
summary: {
|
|
2058
|
+
message_count: history.length,
|
|
2059
|
+
agent_count: Object.keys(agents).length,
|
|
2060
|
+
decision_count: decisions.length,
|
|
2061
|
+
task_count: tasks.length,
|
|
2062
|
+
channel_count: Object.keys(channels).length,
|
|
2063
|
+
time_range: history.length > 0 ? {
|
|
2064
|
+
start: history[0].timestamp,
|
|
2065
|
+
end: history[history.length - 1].timestamp,
|
|
2066
|
+
} : null,
|
|
2067
|
+
},
|
|
2068
|
+
agents,
|
|
2069
|
+
channels,
|
|
2070
|
+
decisions,
|
|
2071
|
+
tasks,
|
|
2072
|
+
messages: history,
|
|
2073
|
+
};
|
|
2074
|
+
res.writeHead(200, {
|
|
2075
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
2076
|
+
'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '-full.json"',
|
|
2077
|
+
});
|
|
2078
|
+
res.end(JSON.stringify(result, null, 2));
|
|
2079
|
+
}
|
|
2080
|
+
else if (url.pathname === '/api/export' && req.method === 'GET') {
|
|
2081
|
+
const html = apiExportHtml(url.searchParams);
|
|
2082
|
+
res.writeHead(200, {
|
|
2083
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
2084
|
+
'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '.html"',
|
|
2085
|
+
});
|
|
2086
|
+
res.end(html);
|
|
2087
|
+
}
|
|
2088
|
+
else if (url.pathname === '/api/discover' && req.method === 'POST') {
|
|
2089
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2090
|
+
res.end(JSON.stringify(apiDiscover()));
|
|
2091
|
+
}
|
|
2092
|
+
// --- v3.0 API endpoints ---
|
|
2093
|
+
else if (url.pathname === '/api/profiles' && req.method === 'GET') {
|
|
2094
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2095
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2096
|
+
res.end(JSON.stringify(readJson(filePath('profiles.json', projectPath))));
|
|
2097
|
+
}
|
|
2098
|
+
else if (url.pathname === '/api/profiles' && req.method === 'POST') {
|
|
2099
|
+
const body = await parseBody(req);
|
|
2100
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2101
|
+
const profilesFile = filePath('profiles.json', projectPath);
|
|
2102
|
+
const profiles = readJson(profilesFile);
|
|
2103
|
+
if (!body.agent || !/^[a-zA-Z0-9_-]{1,20}$/.test(body.agent)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid agent name' })); return; }
|
|
2104
|
+
if (!profiles[body.agent]) profiles[body.agent] = {};
|
|
2105
|
+
if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
|
|
2106
|
+
if (body.avatar) {
|
|
2107
|
+
if (body.avatar.length > 65536) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Avatar too large (max 64KB)' })); return; }
|
|
2108
|
+
profiles[body.agent].avatar = body.avatar;
|
|
2109
|
+
}
|
|
2110
|
+
if (body.bio !== undefined) profiles[body.agent].bio = (body.bio || '').substring(0, 200);
|
|
2111
|
+
if (body.role !== undefined) profiles[body.agent].role = (body.role || '').substring(0, 30);
|
|
2112
|
+
profiles[body.agent].updated_at = new Date().toISOString();
|
|
2113
|
+
fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
|
|
2114
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2115
|
+
res.end(JSON.stringify({ success: true }));
|
|
2116
|
+
}
|
|
2117
|
+
else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
2118
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2119
|
+
const agentParam = url.searchParams.get('agent');
|
|
2120
|
+
if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
|
|
2121
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2122
|
+
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2126
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2127
|
+
const result = {};
|
|
2128
|
+
if (agentParam) {
|
|
2129
|
+
const wsFile = path.join(wsDir, agentParam + '.json');
|
|
2130
|
+
result[agentParam] = fs.existsSync(wsFile) ? readJson(wsFile) : {};
|
|
2131
|
+
} else if (fs.existsSync(wsDir)) {
|
|
2132
|
+
for (const f of fs.readdirSync(wsDir).filter(x => x.endsWith('.json'))) {
|
|
2133
|
+
const name = f.replace('.json', '');
|
|
2134
|
+
result[name] = readJson(path.join(wsDir, f));
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2138
|
+
res.end(JSON.stringify(result));
|
|
2139
|
+
}
|
|
2140
|
+
else if (url.pathname === '/api/workflows' && req.method === 'GET') {
|
|
2141
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2142
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2143
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2144
|
+
res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
|
|
2145
|
+
}
|
|
2146
|
+
else if (url.pathname === '/api/workflows' && req.method === 'POST') {
|
|
2147
|
+
const body = await parseBody(req);
|
|
2148
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2149
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2150
|
+
let workflows = [];
|
|
2151
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2152
|
+
if (body.action === 'advance' && body.workflow_id) {
|
|
2153
|
+
const wf = workflows.find(w => w.id === body.workflow_id);
|
|
2154
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2155
|
+
const curr = wf.steps.find(s => s.status === 'in_progress');
|
|
2156
|
+
if (curr) { curr.status = 'done'; curr.completed_at = new Date().toISOString(); if (body.notes) curr.notes = body.notes; }
|
|
2157
|
+
const next = wf.steps.find(s => s.status === 'pending');
|
|
2158
|
+
if (next) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); } else { wf.status = 'completed'; }
|
|
2159
|
+
wf.updated_at = new Date().toISOString();
|
|
2160
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2161
|
+
} else if (body.action === 'skip' && body.workflow_id && body.step_id) {
|
|
2162
|
+
const wf = workflows.find(w => w.id === body.workflow_id);
|
|
2163
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2164
|
+
const step = wf.steps.find(s => s.id === body.step_id);
|
|
2165
|
+
if (step) { step.status = 'done'; step.notes = 'Skipped from dashboard'; step.completed_at = new Date().toISOString(); }
|
|
2166
|
+
const next = wf.steps.find(s => s.status === 'pending');
|
|
2167
|
+
if (next && !wf.steps.find(s => s.status === 'in_progress')) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); }
|
|
2168
|
+
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
2169
|
+
wf.updated_at = new Date().toISOString();
|
|
2170
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2171
|
+
} else {
|
|
2172
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
|
|
2173
|
+
}
|
|
2174
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2175
|
+
res.end(JSON.stringify({ success: true }));
|
|
2176
|
+
}
|
|
2177
|
+
// ========== Plan Control API (v5.0 Autonomy Engine) ==========
|
|
2178
|
+
|
|
2179
|
+
else if (url.pathname === '/api/plan/status' && req.method === 'GET') {
|
|
2180
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2181
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2182
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2183
|
+
let workflows = [];
|
|
2184
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2185
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2186
|
+
|
|
2187
|
+
// Find the active autonomous workflow (most recent)
|
|
2188
|
+
const activeWf = workflows.filter(w => w.status === 'active' && w.autonomous).pop()
|
|
2189
|
+
|| workflows.filter(w => w.status === 'active').pop();
|
|
2190
|
+
|
|
2191
|
+
if (!activeWf) {
|
|
2192
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2193
|
+
res.end(JSON.stringify({ active: false, message: 'No active plan' }));
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
const doneSteps = activeWf.steps.filter(s => s.status === 'done').length;
|
|
2198
|
+
const totalSteps = activeWf.steps.length;
|
|
2199
|
+
const elapsed = Date.now() - new Date(activeWf.created_at).getTime();
|
|
2200
|
+
const activeAgents = Object.entries(agents).filter(([, a]) => {
|
|
2201
|
+
const idle = Date.now() - new Date(a.last_activity || 0).getTime();
|
|
2202
|
+
return idle < 120000;
|
|
2203
|
+
}).length;
|
|
2204
|
+
|
|
2205
|
+
const retryCount = activeWf.steps.filter(s => s.flagged).length;
|
|
2206
|
+
const avgConfidence = activeWf.steps.filter(s => s.verification && s.verification.confidence)
|
|
2207
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2208
|
+
|
|
2209
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2210
|
+
res.end(JSON.stringify({
|
|
2211
|
+
active: true,
|
|
2212
|
+
workflow_id: activeWf.id,
|
|
2213
|
+
name: activeWf.name,
|
|
2214
|
+
status: activeWf.status,
|
|
2215
|
+
autonomous: !!activeWf.autonomous,
|
|
2216
|
+
parallel: !!activeWf.parallel,
|
|
2217
|
+
paused: !!activeWf.paused,
|
|
2218
|
+
progress: { done: doneSteps, total: totalSteps, percent: Math.round((doneSteps / totalSteps) * 100) },
|
|
2219
|
+
elapsed_ms: elapsed,
|
|
2220
|
+
elapsed_human: Math.round(elapsed / 60000) + 'm',
|
|
2221
|
+
agents_active: activeAgents,
|
|
2222
|
+
steps: activeWf.steps.map(s => ({
|
|
2223
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2224
|
+
status: s.status, depends_on: s.depends_on || [],
|
|
2225
|
+
started_at: s.started_at, completed_at: s.completed_at,
|
|
2226
|
+
flagged: !!s.flagged, flag_reason: s.flag_reason || null,
|
|
2227
|
+
confidence: s.verification ? s.verification.confidence : null,
|
|
2228
|
+
verification: s.verification || null,
|
|
2229
|
+
})),
|
|
2230
|
+
retries: retryCount,
|
|
2231
|
+
avg_confidence: Math.round(avgConfidence) || null,
|
|
2232
|
+
created_at: activeWf.created_at,
|
|
2233
|
+
}));
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
else if (url.pathname === '/api/plan/pause' && req.method === 'POST') {
|
|
2237
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2238
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2239
|
+
let workflows = [];
|
|
2240
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2241
|
+
const activeWf = workflows.find(w => w.status === 'active' && w.autonomous);
|
|
2242
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active autonomous plan' })); return; }
|
|
2243
|
+
activeWf.paused = true;
|
|
2244
|
+
activeWf.paused_at = new Date().toISOString();
|
|
2245
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2246
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2247
|
+
// Notify agents
|
|
2248
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN PAUSED] "${activeWf.name}" has been paused by the dashboard. Finish your current step, then wait for resume.` }, url.searchParams);
|
|
2249
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2250
|
+
res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: activeWf.id }));
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
else if (url.pathname === '/api/plan/resume' && req.method === 'POST') {
|
|
2254
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2255
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2256
|
+
let workflows = [];
|
|
2257
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2258
|
+
const pausedWf = workflows.find(w => w.status === 'active' && w.paused);
|
|
2259
|
+
if (!pausedWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No paused plan' })); return; }
|
|
2260
|
+
pausedWf.paused = false;
|
|
2261
|
+
delete pausedWf.paused_at;
|
|
2262
|
+
pausedWf.updated_at = new Date().toISOString();
|
|
2263
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2264
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${pausedWf.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
|
|
2265
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2266
|
+
res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: pausedWf.id }));
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
else if (url.pathname === '/api/plan/stop' && req.method === 'POST') {
|
|
2270
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2271
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2272
|
+
let workflows = [];
|
|
2273
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2274
|
+
const activeWf = workflows.find(w => w.status === 'active');
|
|
2275
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active plan' })); return; }
|
|
2276
|
+
activeWf.status = 'stopped';
|
|
2277
|
+
activeWf.stopped_at = new Date().toISOString();
|
|
2278
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2279
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2280
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${activeWf.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
|
|
2281
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2282
|
+
res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: activeWf.id }));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
else if (url.pathname.startsWith('/api/plan/skip/') && req.method === 'POST') {
|
|
2286
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2287
|
+
const body = await parseBody(req);
|
|
2288
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2289
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2290
|
+
let workflows = [];
|
|
2291
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2292
|
+
const wfId = body.workflow_id;
|
|
2293
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2294
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2295
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2296
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2297
|
+
step.status = 'done';
|
|
2298
|
+
step.notes = (step.notes || '') + ' [Skipped from dashboard]';
|
|
2299
|
+
step.completed_at = new Date().toISOString();
|
|
2300
|
+
step.skipped = true;
|
|
2301
|
+
// Start any newly ready steps
|
|
2302
|
+
const readySteps = wf.steps.filter(s => {
|
|
2303
|
+
if (s.status !== 'pending') return false;
|
|
2304
|
+
if (!s.depends_on || s.depends_on.length === 0) return true;
|
|
2305
|
+
return s.depends_on.every(depId => { const d = wf.steps.find(x => x.id === depId); return d && d.status === 'done'; });
|
|
2306
|
+
});
|
|
2307
|
+
for (const rs of readySteps) { rs.status = 'in_progress'; rs.started_at = new Date().toISOString(); }
|
|
2308
|
+
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
2309
|
+
wf.updated_at = new Date().toISOString();
|
|
2310
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2312
|
+
res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: readySteps.map(s => s.id) }));
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
else if (url.pathname.startsWith('/api/plan/reassign/') && req.method === 'POST') {
|
|
2316
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2317
|
+
const body = await parseBody(req);
|
|
2318
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2319
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2320
|
+
if (!body.new_assignee) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'new_assignee required' })); return; }
|
|
2321
|
+
let workflows = [];
|
|
2322
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2323
|
+
const wfId = body.workflow_id;
|
|
2324
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2325
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2326
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2327
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2328
|
+
const oldAssignee = step.assignee;
|
|
2329
|
+
step.assignee = body.new_assignee;
|
|
2330
|
+
wf.updated_at = new Date().toISOString();
|
|
2331
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2332
|
+
apiInjectMessage({ to: body.new_assignee, content: `[REASSIGNED] Step ${stepId} "${step.description}" has been reassigned from ${oldAssignee || 'unassigned'} to you. ${step.status === 'in_progress' ? 'This step is IN PROGRESS — pick it up now.' : 'This step is ' + step.status + '.'}` }, url.searchParams);
|
|
2333
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2334
|
+
res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: oldAssignee, new_assignee: body.new_assignee }));
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
else if (url.pathname === '/api/plan/inject' && req.method === 'POST') {
|
|
2338
|
+
const body = await parseBody(req);
|
|
2339
|
+
if (!body.content) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'content required' })); return; }
|
|
2340
|
+
const result = apiInjectMessage({ to: body.to || '__all__', content: body.content }, url.searchParams);
|
|
2341
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2342
|
+
res.end(JSON.stringify(result));
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
else if (url.pathname === '/api/plan/report' && req.method === 'GET') {
|
|
2346
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2347
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2348
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2349
|
+
let workflows = [];
|
|
2350
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2351
|
+
// Get most recent completed or active workflow
|
|
2352
|
+
const wf = workflows.filter(w => w.status === 'completed').pop() || workflows.filter(w => w.status === 'active').pop();
|
|
2353
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
|
|
2354
|
+
|
|
2355
|
+
const doneSteps = wf.steps.filter(s => s.status === 'done');
|
|
2356
|
+
const flaggedSteps = wf.steps.filter(s => s.flagged);
|
|
2357
|
+
const duration = wf.completed_at ? new Date(wf.completed_at) - new Date(wf.created_at) : Date.now() - new Date(wf.created_at).getTime();
|
|
2358
|
+
const avgConf = doneSteps.filter(s => s.verification && s.verification.confidence)
|
|
2359
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2360
|
+
|
|
2361
|
+
// Count skills learned during this plan
|
|
2362
|
+
let skillCount = 0;
|
|
2363
|
+
if (fs.existsSync(kbFile)) {
|
|
2364
|
+
try {
|
|
2365
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2366
|
+
skillCount = Object.keys(kb).filter(k => k.startsWith('skill_') || k.startsWith('lesson_')).length;
|
|
2367
|
+
} catch {}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Agent-level performance analytics
|
|
2371
|
+
const agentStats = {};
|
|
2372
|
+
for (const s of wf.steps) {
|
|
2373
|
+
if (!s.assignee) continue;
|
|
2374
|
+
if (!agentStats[s.assignee]) agentStats[s.assignee] = { steps: 0, completed: 0, flagged: 0, total_ms: 0, confidences: [] };
|
|
2375
|
+
agentStats[s.assignee].steps++;
|
|
2376
|
+
if (s.status === 'done') {
|
|
2377
|
+
agentStats[s.assignee].completed++;
|
|
2378
|
+
if (s.completed_at && s.started_at) agentStats[s.assignee].total_ms += new Date(s.completed_at) - new Date(s.started_at);
|
|
2379
|
+
if (s.verification && s.verification.confidence) agentStats[s.assignee].confidences.push(s.verification.confidence);
|
|
2380
|
+
}
|
|
2381
|
+
if (s.flagged) agentStats[s.assignee].flagged++;
|
|
2382
|
+
}
|
|
2383
|
+
const agentPerformance = Object.entries(agentStats).map(([name, stats]) => ({
|
|
2384
|
+
agent: name, steps_assigned: stats.steps, steps_completed: stats.completed, steps_flagged: stats.flagged,
|
|
2385
|
+
avg_duration_ms: stats.completed > 0 ? Math.round(stats.total_ms / stats.completed) : null,
|
|
2386
|
+
avg_confidence: stats.confidences.length > 0 ? Math.round(stats.confidences.reduce((a, b) => a + b, 0) / stats.confidences.length) : null,
|
|
2387
|
+
}));
|
|
2388
|
+
|
|
2389
|
+
// Slowest/fastest steps
|
|
2390
|
+
const stepsWithDuration = wf.steps.filter(s => s.completed_at && s.started_at)
|
|
2391
|
+
.map(s => ({ id: s.id, description: s.description, assignee: s.assignee, duration_ms: new Date(s.completed_at) - new Date(s.started_at) }))
|
|
2392
|
+
.sort((a, b) => b.duration_ms - a.duration_ms);
|
|
2393
|
+
|
|
2394
|
+
// Retry count from workspace data
|
|
2395
|
+
let retryCount = 0;
|
|
2396
|
+
const wsDir = path.join(resolveDataDir(projectPath), 'workspaces');
|
|
2397
|
+
if (fs.existsSync(wsDir)) {
|
|
2398
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2399
|
+
try {
|
|
2400
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2401
|
+
if (ws.retry_history) {
|
|
2402
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2403
|
+
if (Array.isArray(history)) retryCount += history.length;
|
|
2404
|
+
}
|
|
2405
|
+
} catch {}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2410
|
+
res.end(JSON.stringify({
|
|
2411
|
+
name: wf.name,
|
|
2412
|
+
status: wf.status,
|
|
2413
|
+
steps_done: doneSteps.length,
|
|
2414
|
+
steps_total: wf.steps.length,
|
|
2415
|
+
duration_ms: duration,
|
|
2416
|
+
duration_human: Math.round(duration / 60000) + 'm',
|
|
2417
|
+
avg_confidence: Math.round(avgConf) || null,
|
|
2418
|
+
flagged_steps: flaggedSteps.map(s => ({ id: s.id, description: s.description, reason: s.flag_reason })),
|
|
2419
|
+
skills_learned: skillCount,
|
|
2420
|
+
retries: retryCount,
|
|
2421
|
+
agent_performance: agentPerformance,
|
|
2422
|
+
slowest_step: stepsWithDuration[0] || null,
|
|
2423
|
+
fastest_step: stepsWithDuration[stepsWithDuration.length - 1] || null,
|
|
2424
|
+
steps: wf.steps.map(s => ({
|
|
2425
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2426
|
+
status: s.status, confidence: s.verification ? s.verification.confidence : null,
|
|
2427
|
+
duration_ms: s.completed_at && s.started_at ? new Date(s.completed_at) - new Date(s.started_at) : null,
|
|
2428
|
+
flagged: !!s.flagged, skipped: !!s.skipped,
|
|
2429
|
+
})),
|
|
2430
|
+
created_at: wf.created_at,
|
|
2431
|
+
completed_at: wf.completed_at || null,
|
|
2432
|
+
}));
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
else if (url.pathname === '/api/plan/skills' && req.method === 'GET') {
|
|
2436
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2437
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2438
|
+
let skills = [];
|
|
2439
|
+
if (fs.existsSync(kbFile)) {
|
|
2440
|
+
try {
|
|
2441
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2442
|
+
for (const [key, val] of Object.entries(kb)) {
|
|
2443
|
+
if (key.startsWith('skill_') || key.startsWith('lesson_')) {
|
|
2444
|
+
skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
} catch {}
|
|
2448
|
+
}
|
|
2449
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2450
|
+
res.end(JSON.stringify({ count: skills.length, skills }));
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
else if (url.pathname === '/api/plan/retries' && req.method === 'GET') {
|
|
2454
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2455
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2456
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2457
|
+
let retries = [];
|
|
2458
|
+
if (fs.existsSync(wsDir)) {
|
|
2459
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2460
|
+
try {
|
|
2461
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2462
|
+
if (ws.retry_history) {
|
|
2463
|
+
const agent = file.replace('.json', '');
|
|
2464
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2465
|
+
if (Array.isArray(history)) {
|
|
2466
|
+
for (const entry of history) { retries.push({ agent, ...entry }); }
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
} catch {}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
retries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
|
2473
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2474
|
+
res.end(JSON.stringify({ count: retries.length, retries }));
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// ========== Monitor Agent API ==========
|
|
2478
|
+
|
|
2479
|
+
else if (url.pathname === '/api/monitor/health' && req.method === 'GET') {
|
|
2480
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2481
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2482
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2483
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2484
|
+
const profilesFile = filePath('profiles.json', projectPath);
|
|
2485
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2486
|
+
|
|
2487
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2488
|
+
const profiles = fs.existsSync(profilesFile) ? readJson(profilesFile) : {};
|
|
2489
|
+
let workflows = [];
|
|
2490
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2491
|
+
let tasks = [];
|
|
2492
|
+
if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2493
|
+
|
|
2494
|
+
// Find monitor agent
|
|
2495
|
+
const monitorName = Object.entries(profiles).find(([, p]) => p.role === 'monitor');
|
|
2496
|
+
const now = Date.now();
|
|
2497
|
+
|
|
2498
|
+
// Agent health summary
|
|
2499
|
+
const agentHealth = Object.entries(agents).map(([name, a]) => {
|
|
2500
|
+
const idle = now - new Date(a.last_activity || 0).getTime();
|
|
2501
|
+
return { name, idle_ms: idle, idle_human: Math.round(idle / 1000) + 's', status: idle > 120000 ? 'idle' : idle > 600000 ? 'stuck' : 'active', role: profiles[name] ? profiles[name].role : null };
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
const idleAgents = agentHealth.filter(a => a.status === 'idle').length;
|
|
2505
|
+
const stuckAgents = agentHealth.filter(a => a.status === 'stuck').length;
|
|
2506
|
+
const activeWorkflows = workflows.filter(w => w.status === 'active').length;
|
|
2507
|
+
const pendingTasks = tasks.filter(t => t.status === 'pending').length;
|
|
2508
|
+
const blockedTasks = tasks.filter(t => t.status === 'blocked' || t.status === 'blocked_permanent').length;
|
|
2509
|
+
|
|
2510
|
+
// Monitor intervention log from workspace
|
|
2511
|
+
let interventions = [];
|
|
2512
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2513
|
+
if (monitorName && fs.existsSync(wsDir)) {
|
|
2514
|
+
const monFile = path.join(wsDir, monitorName[0] + '.json');
|
|
2515
|
+
if (fs.existsSync(monFile)) {
|
|
2516
|
+
try {
|
|
2517
|
+
const ws = JSON.parse(fs.readFileSync(monFile, 'utf8'));
|
|
2518
|
+
if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
|
|
2519
|
+
} catch {}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2524
|
+
res.end(JSON.stringify({
|
|
2525
|
+
monitor: monitorName ? { name: monitorName[0], active: true } : { active: false },
|
|
2526
|
+
health: {
|
|
2527
|
+
total_agents: Object.keys(agents).length,
|
|
2528
|
+
active: agentHealth.filter(a => a.status === 'active').length,
|
|
2529
|
+
idle: idleAgents,
|
|
2530
|
+
stuck: stuckAgents,
|
|
2531
|
+
active_workflows: activeWorkflows,
|
|
2532
|
+
pending_tasks: pendingTasks,
|
|
2533
|
+
blocked_tasks: blockedTasks,
|
|
2534
|
+
},
|
|
2535
|
+
agents: agentHealth,
|
|
2536
|
+
interventions: interventions.slice(-20),
|
|
2537
|
+
timestamp: new Date().toISOString(),
|
|
2538
|
+
}));
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// ========== Reputation API ==========
|
|
2542
|
+
|
|
2543
|
+
else if (url.pathname === '/api/reputation' && req.method === 'GET') {
|
|
2544
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2545
|
+
const repFile = filePath('reputation.json', projectPath);
|
|
2546
|
+
const rep = fs.existsSync(repFile) ? readJson(repFile) : {};
|
|
2547
|
+
|
|
2548
|
+
// Calculate scores and build leaderboard
|
|
2549
|
+
const leaderboard = Object.entries(rep).map(([name, r]) => {
|
|
2550
|
+
const score = (r.tasks_completed || 0) * 2
|
|
2551
|
+
+ (r.reviews_done || 0) * 1
|
|
2552
|
+
+ (r.help_given || 0) * 3
|
|
2553
|
+
+ (r.kb_contributions || 0) * 1
|
|
2554
|
+
- (r.retries || 0) * 1
|
|
2555
|
+
- (r.watchdog_nudges || 0) * 2;
|
|
2556
|
+
return {
|
|
2557
|
+
name, score,
|
|
2558
|
+
tasks_completed: r.tasks_completed || 0,
|
|
2559
|
+
reviews_done: r.reviews_done || 0,
|
|
2560
|
+
retries: r.retries || 0,
|
|
2561
|
+
watchdog_nudges: r.watchdog_nudges || 0,
|
|
2562
|
+
help_given: r.help_given || 0,
|
|
2563
|
+
strengths: r.strengths || [],
|
|
2564
|
+
};
|
|
2565
|
+
}).sort((a, b) => b.score - a.score);
|
|
2566
|
+
|
|
2567
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2568
|
+
res.end(JSON.stringify({ leaderboard, timestamp: new Date().toISOString() }));
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// ========== System Stats API ==========
|
|
2572
|
+
|
|
2573
|
+
else if (url.pathname === '/api/stats' && req.method === 'GET') {
|
|
2574
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2575
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2576
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2577
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2578
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2579
|
+
const histFile = path.join(dataDir, 'history.jsonl');
|
|
2580
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2581
|
+
|
|
2582
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2583
|
+
let workflows = []; if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2584
|
+
let tasks = []; if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2585
|
+
let msgCount = 0; if (fs.existsSync(histFile)) { try { const c = fs.readFileSync(histFile, 'utf8').trim(); if (c) msgCount = c.split(/\r?\n/).filter(l => l.trim()).length; } catch {} }
|
|
2586
|
+
let kbKeys = 0; if (fs.existsSync(kbFile)) try { kbKeys = Object.keys(JSON.parse(fs.readFileSync(kbFile, 'utf8'))).length; } catch {}
|
|
2587
|
+
|
|
2588
|
+
const aliveCount = Object.values(agents).filter(a => { const idle = Date.now() - new Date(a.last_activity || 0).getTime(); return idle < 120000; }).length;
|
|
2589
|
+
const activeWf = workflows.filter(w => w.status === 'active');
|
|
2590
|
+
const completedWf = workflows.filter(w => w.status === 'completed');
|
|
2591
|
+
const tasksDone = tasks.filter(t => t.status === 'done').length;
|
|
2592
|
+
const tasksActive = tasks.filter(t => t.status === 'in_progress').length;
|
|
2593
|
+
|
|
2594
|
+
// Heartbeat files count
|
|
2595
|
+
let hbCount = 0;
|
|
2596
|
+
try { hbCount = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-')).length; } catch {}
|
|
2597
|
+
|
|
2598
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2599
|
+
res.end(JSON.stringify({
|
|
2600
|
+
agents: { total: Math.max(Object.keys(agents).length, hbCount), alive: aliveCount },
|
|
2601
|
+
messages: { total: msgCount },
|
|
2602
|
+
tasks: { total: tasks.length, done: tasksDone, active: tasksActive, pending: tasks.length - tasksDone - tasksActive },
|
|
2603
|
+
workflows: { total: workflows.length, active: activeWf.length, completed: completedWf.length },
|
|
2604
|
+
active_plan: activeWf.length > 0 ? { name: activeWf[0].name, progress: activeWf[0].steps.filter(s => s.status === 'done').length + '/' + activeWf[0].steps.length } : null,
|
|
2605
|
+
knowledge_base: { entries: kbKeys },
|
|
2606
|
+
timestamp: new Date().toISOString(),
|
|
2607
|
+
}));
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// ========== Rules API ==========
|
|
2611
|
+
|
|
2612
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
2613
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2614
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2615
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2616
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2617
|
+
res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
2621
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2622
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2623
|
+
try {
|
|
2624
|
+
const body = await parseBody(req);
|
|
2625
|
+
const { text, category } = body;
|
|
2626
|
+
if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
|
|
2627
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2628
|
+
const rule = {
|
|
2629
|
+
id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
2630
|
+
text: text.trim(),
|
|
2631
|
+
category: category || 'custom',
|
|
2632
|
+
created_by: 'dashboard',
|
|
2633
|
+
created_at: new Date().toISOString(),
|
|
2634
|
+
active: true,
|
|
2635
|
+
};
|
|
2636
|
+
rules.push(rule);
|
|
2637
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2638
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
2639
|
+
res.end(JSON.stringify(rule));
|
|
2640
|
+
} catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
|
|
2644
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2645
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2646
|
+
const ruleId = url.pathname.split('/api/rules/')[1];
|
|
2647
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2648
|
+
const idx = rules.findIndex(r => r.id === ruleId);
|
|
2649
|
+
if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2650
|
+
rules.splice(idx, 1);
|
|
2651
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2652
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2653
|
+
res.end(JSON.stringify({ success: true }));
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
|
|
2657
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2658
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2659
|
+
const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
|
|
2660
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2661
|
+
const rule = rules.find(r => r.id === ruleId);
|
|
2662
|
+
if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2663
|
+
rule.active = !rule.active;
|
|
2664
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2665
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2666
|
+
res.end(JSON.stringify(rule));
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// ========== End Rules API ==========
|
|
2670
|
+
|
|
2671
|
+
else if (url.pathname === '/api/branches' && req.method === 'GET') {
|
|
2672
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2673
|
+
const branchesFile = filePath('branches.json', projectPath);
|
|
2674
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2675
|
+
let branches = fs.existsSync(branchesFile) ? readJson(branchesFile) : {};
|
|
2676
|
+
// Add message counts
|
|
2677
|
+
for (const [name, info] of Object.entries(branches)) {
|
|
2678
|
+
const histFile = name === 'main' ? path.join(dataDir, 'history.jsonl') : path.join(dataDir, `branch-${name}-history.jsonl`);
|
|
2679
|
+
let msgCount = 0;
|
|
2680
|
+
if (fs.existsSync(histFile)) {
|
|
2681
|
+
const content = fs.readFileSync(histFile, 'utf8').trim();
|
|
2682
|
+
if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
2683
|
+
}
|
|
2684
|
+
branches[name].message_count = msgCount;
|
|
2685
|
+
}
|
|
2686
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2687
|
+
res.end(JSON.stringify(branches));
|
|
2688
|
+
}
|
|
2689
|
+
else if (url.pathname === '/api/projects' && req.method === 'DELETE') {
|
|
2690
|
+
const body = await parseBody(req);
|
|
2691
|
+
const result = apiRemoveProject(body);
|
|
2692
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2693
|
+
res.end(JSON.stringify(result));
|
|
2694
|
+
}
|
|
2695
|
+
// --- v3.4: Message Edit ---
|
|
2696
|
+
else if (url.pathname === '/api/message' && req.method === 'PUT') {
|
|
2697
|
+
const body = await parseBody(req);
|
|
2698
|
+
const result = await apiEditMessage(body, url.searchParams);
|
|
2699
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2700
|
+
res.end(JSON.stringify(result));
|
|
2701
|
+
}
|
|
2702
|
+
// --- v3.4: Message Delete ---
|
|
2703
|
+
else if (url.pathname === '/api/message' && req.method === 'DELETE') {
|
|
2704
|
+
const body = await parseBody(req);
|
|
2705
|
+
const result = await apiDeleteMessage(body, url.searchParams);
|
|
2706
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2707
|
+
res.end(JSON.stringify(result));
|
|
2708
|
+
}
|
|
2709
|
+
// --- v3.4: Conversation Templates ---
|
|
2710
|
+
else if (url.pathname === '/api/conversation-templates' && req.method === 'GET') {
|
|
2711
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2712
|
+
res.end(JSON.stringify(apiGetConversationTemplates()));
|
|
2713
|
+
}
|
|
2714
|
+
else if (url.pathname === '/api/conversation-templates/launch' && req.method === 'POST') {
|
|
2715
|
+
const body = await parseBody(req);
|
|
2716
|
+
const result = apiLaunchConversationTemplate(body, url.searchParams);
|
|
2717
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2718
|
+
res.end(JSON.stringify(result));
|
|
2719
|
+
}
|
|
2720
|
+
// --- v3.4: Agent Permissions ---
|
|
2721
|
+
else if (url.pathname === '/api/permissions' && req.method === 'GET') {
|
|
2722
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2723
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2724
|
+
res.end(JSON.stringify(readJson(filePath('permissions.json', projectPath))));
|
|
2725
|
+
}
|
|
2726
|
+
else if (url.pathname === '/api/permissions' && req.method === 'POST') {
|
|
2727
|
+
const body = await parseBody(req);
|
|
2728
|
+
const result = apiUpdatePermissions(body, url.searchParams);
|
|
2729
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2730
|
+
res.end(JSON.stringify(result));
|
|
2731
|
+
}
|
|
2732
|
+
// --- v3.4: Read Receipts ---
|
|
2733
|
+
else if (url.pathname === '/api/read-receipts' && req.method === 'GET') {
|
|
2734
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2735
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2736
|
+
res.end(JSON.stringify(readJson(filePath('read_receipts.json', projectPath))));
|
|
2737
|
+
}
|
|
2738
|
+
// Server info (LAN mode detection for frontend)
|
|
2739
|
+
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
2740
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2741
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
|
|
2742
|
+
}
|
|
2743
|
+
// Toggle LAN mode (re-bind server live)
|
|
2744
|
+
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
2745
|
+
const newMode = !LAN_MODE;
|
|
2746
|
+
const lanIP = getLanIP();
|
|
2747
|
+
LAN_MODE = newMode;
|
|
2748
|
+
persistLanMode();
|
|
2749
|
+
// Regenerate token when enabling LAN mode
|
|
2750
|
+
if (newMode) generateLanToken();
|
|
2751
|
+
// Send response first
|
|
2752
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2753
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
|
|
2754
|
+
// Re-bind by stopping the listener and immediately re-listening
|
|
2755
|
+
// Use setImmediate to let the response flush first
|
|
2756
|
+
setImmediate(() => {
|
|
2757
|
+
// Drop SSE clients
|
|
2758
|
+
for (const client of sseClients) { try { client.end(); } catch {} }
|
|
2759
|
+
sseClients.clear();
|
|
2760
|
+
// Stop listening (don't use server.close which waits for all connections)
|
|
2761
|
+
server.listening = false;
|
|
2762
|
+
if (server._handle) {
|
|
2763
|
+
server._handle.close();
|
|
2764
|
+
server._handle = null;
|
|
2765
|
+
}
|
|
2766
|
+
server.listen(PORT, newMode ? '0.0.0.0' : '127.0.0.1', () => {
|
|
2767
|
+
console.log(newMode
|
|
2768
|
+
? ` LAN mode enabled — http://${lanIP}:${PORT}`
|
|
2769
|
+
: ' LAN mode disabled — localhost only');
|
|
2770
|
+
startFileWatcher(); // restart file watcher
|
|
2771
|
+
});
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
// Templates API
|
|
2775
|
+
else if (url.pathname === '/api/templates' && req.method === 'GET') {
|
|
2776
|
+
const templatesDir = path.join(__dirname, 'templates');
|
|
2777
|
+
let templates = [];
|
|
2778
|
+
if (fs.existsSync(templatesDir)) {
|
|
2779
|
+
templates = fs.readdirSync(templatesDir)
|
|
2780
|
+
.filter(f => f.endsWith('.json'))
|
|
2781
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
|
|
2782
|
+
.filter(Boolean);
|
|
2783
|
+
}
|
|
2784
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2785
|
+
res.end(JSON.stringify(templates));
|
|
2786
|
+
}
|
|
2787
|
+
// Agent launcher
|
|
2788
|
+
else if (url.pathname === '/api/launch' && req.method === 'POST') {
|
|
2789
|
+
const body = await parseBody(req);
|
|
2790
|
+
const result = apiLaunchAgent(body);
|
|
2791
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2792
|
+
res.end(JSON.stringify(result));
|
|
2793
|
+
}
|
|
2794
|
+
// --- v3.4: Notifications ---
|
|
2795
|
+
else if (url.pathname === '/api/notifications' && req.method === 'GET') {
|
|
2796
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2797
|
+
res.end(JSON.stringify(apiNotifications()));
|
|
2798
|
+
}
|
|
2799
|
+
// --- v3.4: Performance Scores ---
|
|
2800
|
+
else if (url.pathname === '/api/scores' && req.method === 'GET') {
|
|
2801
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2802
|
+
res.end(JSON.stringify(apiScores(url.searchParams)));
|
|
2803
|
+
}
|
|
2804
|
+
// --- v3.4: Cross-Project Search ---
|
|
2805
|
+
else if (url.pathname === '/api/search-all' && req.method === 'GET') {
|
|
2806
|
+
const result = apiSearchAll(url.searchParams);
|
|
2807
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2808
|
+
res.end(JSON.stringify(result));
|
|
2809
|
+
}
|
|
2810
|
+
// --- v3.4: Replay Export ---
|
|
2811
|
+
else if (url.pathname === '/api/export-replay' && req.method === 'GET') {
|
|
2812
|
+
const html = apiExportReplay(url.searchParams);
|
|
2813
|
+
res.writeHead(200, {
|
|
2814
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
2815
|
+
'Content-Disposition': 'attachment; filename="replay-' + new Date().toISOString().slice(0, 10) + '.html"',
|
|
2816
|
+
});
|
|
2817
|
+
res.end(html);
|
|
2818
|
+
}
|
|
2819
|
+
// (World Builder API endpoints are handled earlier in the route chain by Architect's implementation)
|
|
2820
|
+
// Server-Sent Events endpoint for real-time updates
|
|
2821
|
+
else if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
2822
|
+
if (sseClients.size >= 100) {
|
|
2823
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
2824
|
+
res.end(JSON.stringify({ error: 'Too many SSE connections' }));
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
// Per-IP SSE limit (max 5 connections per IP)
|
|
2828
|
+
const sseIP = req.socket.remoteAddress || 'unknown';
|
|
2829
|
+
const sseIPCount = [...sseClients].filter(c => c._sseIP === sseIP).length;
|
|
2830
|
+
if (sseIPCount >= 5) {
|
|
2831
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
2832
|
+
res.end(JSON.stringify({ error: 'Too many SSE connections from this IP (max 5)' }));
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
res.writeHead(200, {
|
|
2836
|
+
'Content-Type': 'text/event-stream',
|
|
2837
|
+
'Cache-Control': 'no-cache',
|
|
2838
|
+
'Connection': 'keep-alive',
|
|
2839
|
+
});
|
|
2840
|
+
res.write(`data: connected\n\n`);
|
|
2841
|
+
res._sseIP = sseIP;
|
|
2842
|
+
sseClients.add(res);
|
|
2843
|
+
// Heartbeat every 30s to detect dead connections and prevent proxy timeouts
|
|
2844
|
+
const heartbeat = setInterval(() => {
|
|
2845
|
+
try { res.write(`:heartbeat\n\n`); } catch { clearInterval(heartbeat); sseClients.delete(res); }
|
|
2846
|
+
}, 30000);
|
|
2847
|
+
heartbeat.unref();
|
|
2848
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
|
2849
|
+
}
|
|
2850
|
+
else {
|
|
2851
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2852
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
2853
|
+
}
|
|
2854
|
+
} catch (err) {
|
|
2855
|
+
console.error('Server error:', err.message);
|
|
2856
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2857
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
2858
|
+
}
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
// --- Server-Sent Events for real-time updates ---
|
|
2862
|
+
// Watches data files and pushes updates to connected clients instantly
|
|
2863
|
+
const sseClients = new Set();
|
|
2864
|
+
|
|
2865
|
+
function sseNotifyAll(changeType) {
|
|
2866
|
+
// Generate notifications from agent state changes
|
|
2867
|
+
try {
|
|
2868
|
+
const agents = readJson(filePath('agents.json'));
|
|
2869
|
+
generateNotifications(agents);
|
|
2870
|
+
} catch {}
|
|
2871
|
+
|
|
2872
|
+
// Send typed change event so client can do targeted fetches
|
|
2873
|
+
const eventData = changeType || 'update';
|
|
2874
|
+
const dead = [];
|
|
2875
|
+
for (const res of Array.from(sseClients)) {
|
|
2876
|
+
try {
|
|
2877
|
+
res.write(`data: ${(eventData || '').replace(/[\r\n]/g, '')}\n\n`);
|
|
2878
|
+
} catch {
|
|
2879
|
+
dead.push(res);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
for (const res of dead) sseClients.delete(res);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// Watch data directory for changes and push SSE notifications
|
|
2886
|
+
let fsWatcher = null;
|
|
2887
|
+
let sseDebounceTimer = null;
|
|
2888
|
+
|
|
2889
|
+
function startFileWatcher() {
|
|
2890
|
+
// Clean up previous watcher to prevent memory leaks on LAN toggle
|
|
2891
|
+
if (fsWatcher) { try { fsWatcher.close(); } catch {} fsWatcher = null; }
|
|
2892
|
+
if (sseDebounceTimer) { clearTimeout(sseDebounceTimer); sseDebounceTimer = null; }
|
|
2893
|
+
|
|
2894
|
+
const dataDir = resolveDataDir();
|
|
2895
|
+
if (!fs.existsSync(dataDir)) return;
|
|
2896
|
+
try {
|
|
2897
|
+
// Track pending change types for diff-based SSE
|
|
2898
|
+
let pendingChangeTypes = new Set();
|
|
2899
|
+
fsWatcher = fs.watch(dataDir, { persistent: false }, (eventType, filename) => {
|
|
2900
|
+
// Filter: only react to data files, not temp/lock files
|
|
2901
|
+
if (filename && !filename.endsWith('.json') && !filename.endsWith('.jsonl')) return;
|
|
2902
|
+
if (filename && filename.endsWith('.lock')) return;
|
|
2903
|
+
// Scale fix: skip heartbeat file changes — they fire 100x/10s at scale
|
|
2904
|
+
// Dashboard already polls agents via /api/agents on its own interval
|
|
2905
|
+
if (filename && filename.startsWith('heartbeat-')) return;
|
|
2906
|
+
|
|
2907
|
+
// Classify change type for targeted client fetches
|
|
2908
|
+
if (filename === 'messages.jsonl' || filename === 'history.jsonl' || (filename && filename.includes('-messages.jsonl'))) {
|
|
2909
|
+
pendingChangeTypes.add('messages');
|
|
2910
|
+
} else if (filename === 'agents.json' || filename === 'profiles.json') {
|
|
2911
|
+
pendingChangeTypes.add('agents');
|
|
2912
|
+
} else if (filename === 'tasks.json') {
|
|
2913
|
+
pendingChangeTypes.add('tasks');
|
|
2914
|
+
} else if (filename === 'workflows.json') {
|
|
2915
|
+
pendingChangeTypes.add('workflows');
|
|
2916
|
+
} else {
|
|
2917
|
+
pendingChangeTypes.add('update');
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// Debounce — multiple file changes may fire rapidly
|
|
2921
|
+
// Increased from 200ms to 2000ms for 100-agent scale (prevents SSE flood)
|
|
2922
|
+
if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
|
|
2923
|
+
sseDebounceTimer = setTimeout(() => {
|
|
2924
|
+
// Send combined change types: "messages,agents" or just "messages"
|
|
2925
|
+
const changeType = Array.from(pendingChangeTypes).join(',');
|
|
2926
|
+
pendingChangeTypes.clear();
|
|
2927
|
+
sseNotifyAll(changeType);
|
|
2928
|
+
}, 2000);
|
|
2929
|
+
});
|
|
2930
|
+
fsWatcher.on('error', () => {}); // ignore watch errors
|
|
2931
|
+
} catch {}
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
startFileWatcher();
|
|
2935
|
+
|
|
2936
|
+
server.on('error', (err) => {
|
|
2937
|
+
if (err.code === 'EADDRINUSE') {
|
|
2938
|
+
console.error(`\n Error: Port ${PORT} is already in use.`);
|
|
2939
|
+
console.error(` Another dashboard may be running. Try:`);
|
|
2940
|
+
console.error(` - Kill it: npx kill-port ${PORT}`);
|
|
2941
|
+
console.error(` - Or use a different port: NEOHIVE_PORT=3001 npx neohive dashboard\n`);
|
|
2942
|
+
process.exit(1);
|
|
2943
|
+
}
|
|
2944
|
+
throw err;
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
2948
|
+
const dataDir = resolveDataDir();
|
|
2949
|
+
const lanIP = getLanIP();
|
|
2950
|
+
console.log('');
|
|
2951
|
+
console.log(' Neohive - Neohive Dashboard v3.5.1');
|
|
2952
|
+
console.log(' ============================================');
|
|
2953
|
+
console.log(' Dashboard: http://localhost:' + PORT);
|
|
2954
|
+
if (LAN_MODE && lanIP) {
|
|
2955
|
+
console.log(' LAN access: http://' + lanIP + ':' + PORT);
|
|
2956
|
+
console.log(' WARNING: LAN mode enabled — accessible to anyone on your network');
|
|
2957
|
+
}
|
|
2958
|
+
console.log(' Data dir: ' + dataDir);
|
|
2959
|
+
console.log(' Projects: ' + getProjects().length + ' registered');
|
|
2960
|
+
console.log(' Updates: SSE (real-time) + polling fallback (2s)');
|
|
2961
|
+
console.log('');
|
|
2962
|
+
});
|