neohive 6.0.0 → 6.0.3
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 +7 -0
- package/cli.js +134 -33
- package/conversation-templates/autonomous-feature.json +54 -4
- package/conversation-templates/code-review.json +41 -3
- package/conversation-templates/debug-squad.json +41 -3
- package/conversation-templates/feature-build.json +41 -3
- package/conversation-templates/research-write.json +41 -3
- package/dashboard.html +2001 -691
- package/dashboard.js +719 -67
- package/lib/compact.js +5 -2
- package/lib/config.js +4 -3
- package/lib/file-io.js +3 -3
- package/lib/resolve-server-data-dir.js +96 -0
- package/package.json +2 -2
- package/server.js +872 -148
- package/templates/debate.json +24 -5
- package/templates/managed.json +48 -9
- package/templates/pair.json +22 -3
- package/templates/review.json +26 -5
- package/templates/team.json +38 -8
package/dashboard.js
CHANGED
|
@@ -5,6 +5,31 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { spawn } = require('child_process');
|
|
7
7
|
|
|
8
|
+
function findCursorProjectRootWithNeohive(startDir) {
|
|
9
|
+
let dir = path.resolve(startDir);
|
|
10
|
+
const root = path.parse(dir).root;
|
|
11
|
+
while (true) {
|
|
12
|
+
const mcpPath = path.join(dir, '.cursor', 'mcp.json');
|
|
13
|
+
if (fs.existsSync(mcpPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const j = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
|
|
16
|
+
if (j.mcpServers && j.mcpServers.neohive) return dir;
|
|
17
|
+
} catch {}
|
|
18
|
+
}
|
|
19
|
+
if (dir === root) break;
|
|
20
|
+
dir = path.dirname(dir);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeNeohiveDataDirString(raw, workspaceRoot) {
|
|
26
|
+
if (raw == null || typeof raw !== 'string') return null;
|
|
27
|
+
let d = raw.trim();
|
|
28
|
+
if (!d) return null;
|
|
29
|
+
d = d.replace(/\$\{workspaceFolder\}/gi, workspaceRoot);
|
|
30
|
+
return path.isAbsolute(d) ? path.resolve(d) : path.resolve(workspaceRoot, d);
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
// --- File-level mutex for serializing read-then-write operations ---
|
|
9
34
|
const lockMap = new Map();
|
|
10
35
|
function withFileLock(filePath, fn) {
|
|
@@ -15,6 +40,7 @@ function withFileLock(filePath, fn) {
|
|
|
15
40
|
}
|
|
16
41
|
|
|
17
42
|
const PORT = parseInt(process.env.NEOHIVE_PORT || '3000', 10);
|
|
43
|
+
const SERVER_START_TIME = Date.now();
|
|
18
44
|
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
19
45
|
let LAN_MODE = process.env.NEOHIVE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
20
46
|
|
|
@@ -56,7 +82,124 @@ function getLanIP() {
|
|
|
56
82
|
}
|
|
57
83
|
return fallback;
|
|
58
84
|
}
|
|
59
|
-
|
|
85
|
+
|
|
86
|
+
// Check if a directory has actual data files (not just an empty dir)
|
|
87
|
+
function hasDataFiles(dir) {
|
|
88
|
+
if (!fs.existsSync(dir)) return false;
|
|
89
|
+
try {
|
|
90
|
+
const files = fs.readdirSync(dir);
|
|
91
|
+
return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
|
|
92
|
+
} catch { return false; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function countAgentsInNeohiveDir(nhDir) {
|
|
96
|
+
if (!fs.existsSync(nhDir)) return 0;
|
|
97
|
+
const ag = path.join(nhDir, 'agents.json');
|
|
98
|
+
if (!fs.existsSync(ag)) return 0;
|
|
99
|
+
try {
|
|
100
|
+
const j = JSON.parse(fs.readFileSync(ag, 'utf8'));
|
|
101
|
+
return j && typeof j === 'object' ? Object.keys(j).length : 0;
|
|
102
|
+
} catch { return 0; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function countNeohiveJsonArray(filePath) {
|
|
106
|
+
if (!fs.existsSync(filePath)) return 0;
|
|
107
|
+
try {
|
|
108
|
+
const j = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
109
|
+
return Array.isArray(j) ? j.length : 0;
|
|
110
|
+
} catch { return 0; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function neohiveHasTasksOrWorkflows(nhDir) {
|
|
114
|
+
return countNeohiveJsonArray(path.join(nhDir, 'tasks.json')) > 0
|
|
115
|
+
|| countNeohiveJsonArray(path.join(nhDir, 'workflows.json')) > 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Score each ancestor’s .neohive so we prefer the hive that has tasks/workflows (not the first with only agents).
|
|
119
|
+
function scoreNeohiveDataDir(nhDir) {
|
|
120
|
+
if (!fs.existsSync(nhDir)) return -1;
|
|
121
|
+
let s = countAgentsInNeohiveDir(nhDir) * 10;
|
|
122
|
+
s += countNeohiveJsonArray(path.join(nhDir, 'tasks.json'));
|
|
123
|
+
s += countNeohiveJsonArray(path.join(nhDir, 'workflows.json')) * 3;
|
|
124
|
+
if (hasDataFiles(nhDir)) s += 5;
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function bestNeohiveAmongAncestors(startDir) {
|
|
129
|
+
let dir = path.resolve(startDir);
|
|
130
|
+
const root = path.parse(dir).root;
|
|
131
|
+
let best = null;
|
|
132
|
+
let bestScore = -1;
|
|
133
|
+
for (let d = 0; d < 24 && dir !== root; d++) {
|
|
134
|
+
const nh = path.join(dir, '.neohive');
|
|
135
|
+
const sc = scoreNeohiveDataDir(nh);
|
|
136
|
+
if (sc > bestScore) {
|
|
137
|
+
bestScore = sc;
|
|
138
|
+
best = nh;
|
|
139
|
+
}
|
|
140
|
+
dir = path.dirname(dir);
|
|
141
|
+
}
|
|
142
|
+
if (bestScore <= 0) return null;
|
|
143
|
+
return best;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Read NEOHIVE_DATA_DIR from project-local MCP configs (same files init writes).
|
|
147
|
+
// Cursor uses ${workspaceFolder} in .cursor/mcp.json — expand using projectRoot when parsing files.
|
|
148
|
+
function readNeohiveDataDirFromMcpConfigs(projectRoot) {
|
|
149
|
+
const candidates = [
|
|
150
|
+
path.join(projectRoot, '.cursor', 'mcp.json'),
|
|
151
|
+
path.join(projectRoot, '.mcp.json'),
|
|
152
|
+
path.join(projectRoot, '.gemini', 'settings.json'),
|
|
153
|
+
];
|
|
154
|
+
for (const filePath of candidates) {
|
|
155
|
+
if (!fs.existsSync(filePath)) continue;
|
|
156
|
+
try {
|
|
157
|
+
const j = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
158
|
+
const nh = j.mcpServers && j.mcpServers.neohive;
|
|
159
|
+
const raw = nh && nh.env && nh.env.NEOHIVE_DATA_DIR;
|
|
160
|
+
const out = normalizeNeohiveDataDirString(raw, projectRoot);
|
|
161
|
+
if (out) return out;
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveDashboardDefaultDataDir() {
|
|
168
|
+
let envData = process.env.NEOHIVE_DATA_DIR || process.env.NEOHIVE_DATA;
|
|
169
|
+
if (envData && String(envData).trim()) {
|
|
170
|
+
let s = String(envData).trim();
|
|
171
|
+
if (/\$\{workspaceFolder\}/i.test(s)) {
|
|
172
|
+
const root = findCursorProjectRootWithNeohive(process.cwd());
|
|
173
|
+
if (root) s = s.replace(/\$\{workspaceFolder\}/gi, root);
|
|
174
|
+
}
|
|
175
|
+
return { path: path.resolve(s), source: 'environment' };
|
|
176
|
+
}
|
|
177
|
+
const fromWalk = bestNeohiveAmongAncestors(process.cwd());
|
|
178
|
+
if (fromWalk) {
|
|
179
|
+
return { path: fromWalk, source: 'walk-up' };
|
|
180
|
+
}
|
|
181
|
+
let dir = path.resolve(process.cwd());
|
|
182
|
+
const root = path.parse(dir).root;
|
|
183
|
+
while (true) {
|
|
184
|
+
const fromMcp = readNeohiveDataDirFromMcpConfigs(dir);
|
|
185
|
+
if (fromMcp) {
|
|
186
|
+
return { path: fromMcp, source: 'mcp-config', configAt: dir };
|
|
187
|
+
}
|
|
188
|
+
if (dir === root) break;
|
|
189
|
+
dir = path.dirname(dir);
|
|
190
|
+
}
|
|
191
|
+
return { path: path.join(process.cwd(), '.neohive'), source: 'cwd' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const _defaultDataResolved = resolveDashboardDefaultDataDir();
|
|
195
|
+
const DEFAULT_DATA_DIR = _defaultDataResolved.path;
|
|
196
|
+
|
|
197
|
+
// Auto-migrate from .agent-bridge/ to .neohive/ (v5 → v6 rename)
|
|
198
|
+
const _legacyDir = path.join(path.dirname(DEFAULT_DATA_DIR), '.agent-bridge');
|
|
199
|
+
if (!fs.existsSync(DEFAULT_DATA_DIR) && fs.existsSync(_legacyDir)) {
|
|
200
|
+
try { fs.renameSync(_legacyDir, DEFAULT_DATA_DIR); } catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
60
203
|
const HTML_FILE = path.join(__dirname, 'dashboard.html');
|
|
61
204
|
const LOGO_FILE = path.join(__dirname, 'logo.png');
|
|
62
205
|
const PROJECTS_FILE = path.join(__dirname, 'projects.json');
|
|
@@ -72,22 +215,24 @@ function saveProjects(projects) {
|
|
|
72
215
|
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
|
|
73
216
|
}
|
|
74
217
|
|
|
75
|
-
//
|
|
76
|
-
function
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
81
|
-
}
|
|
218
|
+
// Multi-project paths must be the repo root, not .../project/.neohive (otherwise we join .neohive twice).
|
|
219
|
+
function normalizeMonitoredProjectRoot(projectPath) {
|
|
220
|
+
if (!projectPath) return projectPath;
|
|
221
|
+
const p = path.resolve(projectPath);
|
|
222
|
+
if (path.basename(p) === '.neohive') {
|
|
223
|
+
return path.dirname(p);
|
|
224
|
+
}
|
|
225
|
+
return p;
|
|
82
226
|
}
|
|
83
227
|
|
|
84
228
|
// Resolve data dir: explicit project path > env var > cwd > legacy fallback
|
|
85
229
|
// Prefers directories with actual data files over empty ones
|
|
86
230
|
function resolveDataDir(projectPath) {
|
|
87
231
|
if (projectPath) {
|
|
88
|
-
|
|
232
|
+
projectPath = normalizeMonitoredProjectRoot(projectPath);
|
|
233
|
+
let dir = path.join(projectPath, '.neohive');
|
|
89
234
|
const dataDir = path.join(projectPath, 'data');
|
|
90
|
-
// Prefer whichever has data
|
|
235
|
+
// Prefer whichever has data (local hive only — do not redirect agents/messages to parent)
|
|
91
236
|
if (hasDataFiles(dir)) return dir;
|
|
92
237
|
if (hasDataFiles(dataDir)) return dataDir;
|
|
93
238
|
if (fs.existsSync(dir)) return dir;
|
|
@@ -103,19 +248,35 @@ function resolveDataDir(projectPath) {
|
|
|
103
248
|
return DEFAULT_DATA_DIR;
|
|
104
249
|
}
|
|
105
250
|
|
|
251
|
+
// Monorepo: tasks/workflows may live in parent .neohive while agents.json stays in the subfolder.
|
|
252
|
+
// Using parent for *all* files hid agents (empty parent agents.json). Only tasks + workflows use this.
|
|
253
|
+
function resolveTasksWorkflowsDataDir(projectPath) {
|
|
254
|
+
if (!projectPath) return resolveDataDir(null);
|
|
255
|
+
projectPath = normalizeMonitoredProjectRoot(projectPath);
|
|
256
|
+
const localHive = path.join(projectPath, '.neohive');
|
|
257
|
+
const parentHive = path.join(path.dirname(projectPath), '.neohive');
|
|
258
|
+
if (!neohiveHasTasksOrWorkflows(localHive) && neohiveHasTasksOrWorkflows(parentHive)) {
|
|
259
|
+
return parentHive;
|
|
260
|
+
}
|
|
261
|
+
return resolveDataDir(projectPath);
|
|
262
|
+
}
|
|
263
|
+
|
|
106
264
|
function filePath(name, projectPath) {
|
|
107
|
-
|
|
265
|
+
const dir = (name === 'tasks.json' || name === 'workflows.json')
|
|
266
|
+
? resolveTasksWorkflowsDataDir(projectPath)
|
|
267
|
+
: resolveDataDir(projectPath);
|
|
268
|
+
return path.join(dir, name);
|
|
108
269
|
}
|
|
109
270
|
|
|
110
271
|
// Validate project path is registered or is the default
|
|
111
272
|
function validateProjectPath(projectPath) {
|
|
112
273
|
if (!projectPath) return true;
|
|
113
|
-
const absPath = path.resolve(projectPath);
|
|
274
|
+
const absPath = normalizeMonitoredProjectRoot(path.resolve(projectPath));
|
|
114
275
|
const projects = getProjects();
|
|
115
276
|
const cwd = path.resolve(process.cwd());
|
|
116
277
|
const scriptDir = path.resolve(__dirname);
|
|
117
278
|
if (absPath === cwd || absPath === scriptDir) return true;
|
|
118
|
-
return projects.some(p => path.resolve(p.path) === absPath);
|
|
279
|
+
return projects.some(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) === absPath);
|
|
119
280
|
}
|
|
120
281
|
|
|
121
282
|
function htmlEscape(s) {
|
|
@@ -139,7 +300,7 @@ function readJson(file) {
|
|
|
139
300
|
}
|
|
140
301
|
|
|
141
302
|
function isPidAlive(pid, lastActivity) {
|
|
142
|
-
const STALE_THRESHOLD =
|
|
303
|
+
const STALE_THRESHOLD = 30000; // 30s — 3x heartbeat interval, catches dead agents faster
|
|
143
304
|
|
|
144
305
|
// PRIORITY 1: Trust heartbeat freshness over PID status
|
|
145
306
|
// Heartbeats are written by the actual running process — if fresh, agent is alive
|
|
@@ -160,18 +321,18 @@ function isPidAlive(pid, lastActivity) {
|
|
|
160
321
|
|
|
161
322
|
// --- Default avatar helpers ---
|
|
162
323
|
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='%
|
|
324
|
+
"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='%23f59e0b'/%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='%23f59e0b' stroke='%23fff' stroke-width='1.5'/%3E%3Crect x='44' y='12' width='6' height='10' rx='3' fill='%23f59e0b' stroke='%23fff' stroke-width='1.5'/%3E%3C/svg%3E",
|
|
164
325
|
"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
326
|
"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
327
|
"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='%
|
|
328
|
+
"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='%23fb923c'/%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
329
|
"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='%
|
|
330
|
+
"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='%23fbbf24'/%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
331
|
"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
332
|
"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
333
|
"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='%
|
|
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='%
|
|
334
|
+
"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='%23d97706'/%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='%23d97706'/%3E%3Ccircle cx='41' cy='25' r='2' fill='%23d97706'/%3E%3Crect x='26' y='38' width='12' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E",
|
|
335
|
+
"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='%23b45309'/%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='%23b45309'/%3E%3Ccircle cx='40' cy='24' r='2' fill='%23b45309'/%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
336
|
];
|
|
176
337
|
|
|
177
338
|
function hashName(name) {
|
|
@@ -268,15 +429,14 @@ function apiAgents(query) {
|
|
|
268
429
|
const projectPath = query.get('project') || null;
|
|
269
430
|
const agents = readJson(filePath('agents.json', projectPath));
|
|
270
431
|
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
432
|
+
const cards = readJson(filePath('agent-cards.json', projectPath));
|
|
271
433
|
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
272
434
|
|
|
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
435
|
const dataDir = resolveDataDir(projectPath);
|
|
276
436
|
try {
|
|
277
437
|
const hbFiles = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-') && f.endsWith('.json'));
|
|
278
438
|
for (const f of hbFiles) {
|
|
279
|
-
const name = f.slice(10, -5);
|
|
439
|
+
const name = f.slice(10, -5);
|
|
280
440
|
if (agents[name]) {
|
|
281
441
|
try {
|
|
282
442
|
const hb = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
|
|
@@ -286,6 +446,7 @@ function apiAgents(query) {
|
|
|
286
446
|
}
|
|
287
447
|
}
|
|
288
448
|
} catch {}
|
|
449
|
+
|
|
289
450
|
const result = {};
|
|
290
451
|
|
|
291
452
|
// Build last message timestamp per agent from history
|
|
@@ -307,7 +468,7 @@ function apiAgents(query) {
|
|
|
307
468
|
last_activity: lastActivity,
|
|
308
469
|
last_message: lastMessageTime[name] || null,
|
|
309
470
|
idle_seconds: alive ? idleSeconds : null,
|
|
310
|
-
status: !alive ? '
|
|
471
|
+
status: !alive ? 'offline' : (info.listening_since && alive) ? 'listening' : idleSeconds > 30 ? 'idle' : 'working',
|
|
311
472
|
listening_since: info.listening_since || null,
|
|
312
473
|
is_listening: !!(info.listening_since && alive),
|
|
313
474
|
provider: info.provider || 'unknown',
|
|
@@ -319,6 +480,7 @@ function apiAgents(query) {
|
|
|
319
480
|
appearance: profile.appearance || {},
|
|
320
481
|
hostname: info.hostname || null,
|
|
321
482
|
is_remote: !isLocal && alive,
|
|
483
|
+
skills: (cards && cards[name] && cards[name].skills) || [],
|
|
322
484
|
};
|
|
323
485
|
// Include workspace status for agent intent board
|
|
324
486
|
try {
|
|
@@ -345,7 +507,7 @@ function apiStatus(query) {
|
|
|
345
507
|
if (!isPidAlive(a.pid, a.last_activity)) return false;
|
|
346
508
|
const lastActivity = a.last_activity || a.timestamp;
|
|
347
509
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
348
|
-
return idleSeconds >
|
|
510
|
+
return idleSeconds > 30;
|
|
349
511
|
}).length;
|
|
350
512
|
|
|
351
513
|
// Include managed mode status if active
|
|
@@ -357,6 +519,7 @@ function apiStatus(query) {
|
|
|
357
519
|
sleepingCount,
|
|
358
520
|
threadCount: threads.size,
|
|
359
521
|
conversation_mode: config.conversation_mode || 'direct',
|
|
522
|
+
coordinator_mode: config.coordinator_mode || 'responsive',
|
|
360
523
|
};
|
|
361
524
|
|
|
362
525
|
if (config.conversation_mode === 'managed' && config.managed) {
|
|
@@ -483,6 +646,96 @@ function generateNotifications(currentAgents) {
|
|
|
483
646
|
}
|
|
484
647
|
}
|
|
485
648
|
|
|
649
|
+
// --- Token Usage Tracking ---
|
|
650
|
+
// Pricing per 1M tokens (USD)
|
|
651
|
+
const TOKEN_PRICING = {
|
|
652
|
+
'claude-opus-4-6': { input: 15.00, output: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
653
|
+
'claude-sonnet-4-6': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
654
|
+
'claude-haiku-4-5': { input: 0.80, output: 4.00, cache_write: 1.00, cache_read: 0.08 },
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
function parseSessionUsage(sessionFile, maxBytes) {
|
|
658
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, messages: 0, model: null };
|
|
659
|
+
try {
|
|
660
|
+
const stat = fs.statSync(sessionFile);
|
|
661
|
+
// For huge files, only read the last portion to avoid memory issues
|
|
662
|
+
const readSize = Math.min(stat.size, maxBytes || 5 * 1024 * 1024); // 5MB max
|
|
663
|
+
const fd = fs.openSync(sessionFile, 'r');
|
|
664
|
+
const buf = Buffer.alloc(readSize);
|
|
665
|
+
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
|
666
|
+
fs.closeSync(fd);
|
|
667
|
+
const content = buf.toString('utf8');
|
|
668
|
+
// Find complete lines (skip partial first line if we started mid-file)
|
|
669
|
+
const lines = content.split('\n');
|
|
670
|
+
if (stat.size > readSize) lines.shift(); // skip potentially partial first line
|
|
671
|
+
for (const line of lines) {
|
|
672
|
+
if (!line.trim() || !line.includes('"usage"')) continue;
|
|
673
|
+
try {
|
|
674
|
+
const entry = JSON.parse(line);
|
|
675
|
+
if (entry.type === 'assistant' && entry.message && entry.message.usage) {
|
|
676
|
+
const u = entry.message.usage;
|
|
677
|
+
usage.input_tokens += u.input_tokens || 0;
|
|
678
|
+
usage.output_tokens += u.output_tokens || 0;
|
|
679
|
+
usage.cache_creation_tokens += u.cache_creation_input_tokens || 0;
|
|
680
|
+
usage.cache_read_tokens += u.cache_read_input_tokens || 0;
|
|
681
|
+
usage.messages++;
|
|
682
|
+
if (entry.message.model) usage.model = entry.message.model;
|
|
683
|
+
}
|
|
684
|
+
} catch { /* skip unparseable lines */ }
|
|
685
|
+
}
|
|
686
|
+
} catch (e) { /* session file unreadable */ }
|
|
687
|
+
return usage;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function apiTokenUsage(query) {
|
|
691
|
+
const projectPath = query.get('project') || null;
|
|
692
|
+
const dataDir = resolveDataDir(projectPath);
|
|
693
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
694
|
+
const home = os.homedir();
|
|
695
|
+
const sessionsDir = path.join(home, '.claude', 'sessions');
|
|
696
|
+
// Build project slug matching Claude Code's format
|
|
697
|
+
const projectAbsPath = projectPath ? path.resolve(projectPath) : path.resolve(process.cwd());
|
|
698
|
+
const projectSlug = projectAbsPath.replace(/\//g, '-');
|
|
699
|
+
const projectSessionDir = path.join(home, '.claude', 'projects', projectSlug);
|
|
700
|
+
|
|
701
|
+
const result = { agents: {}, total_cost_usd: 0, total_tokens: 0 };
|
|
702
|
+
|
|
703
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
704
|
+
if (!info.pid) continue;
|
|
705
|
+
try {
|
|
706
|
+
// Map CLI PID (ppid) → session ID → session file. Fall back to pid if ppid not available.
|
|
707
|
+
const cliPid = info.ppid || info.pid;
|
|
708
|
+
const pidFile = path.join(sessionsDir, cliPid + '.json');
|
|
709
|
+
if (!fs.existsSync(pidFile)) continue;
|
|
710
|
+
const session = readJson(pidFile);
|
|
711
|
+
if (!session || !session.sessionId) continue;
|
|
712
|
+
const sessionFile = path.join(projectSessionDir, session.sessionId + '.jsonl');
|
|
713
|
+
if (!fs.existsSync(sessionFile)) continue;
|
|
714
|
+
|
|
715
|
+
const usage = parseSessionUsage(sessionFile);
|
|
716
|
+
// Calculate cost
|
|
717
|
+
const pricing = TOKEN_PRICING[usage.model] || TOKEN_PRICING['claude-opus-4-6'];
|
|
718
|
+
const cost = (usage.input_tokens * pricing.input + usage.output_tokens * pricing.output + usage.cache_creation_tokens * pricing.cache_write + usage.cache_read_tokens * pricing.cache_read) / 1000000;
|
|
719
|
+
|
|
720
|
+
result.agents[name] = {
|
|
721
|
+
model: usage.model,
|
|
722
|
+
input_tokens: usage.input_tokens,
|
|
723
|
+
output_tokens: usage.output_tokens,
|
|
724
|
+
cache_creation_tokens: usage.cache_creation_tokens,
|
|
725
|
+
cache_read_tokens: usage.cache_read_tokens,
|
|
726
|
+
total_tokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_tokens + usage.cache_read_tokens,
|
|
727
|
+
estimated_cost_usd: Math.round(cost * 100) / 100,
|
|
728
|
+
messages: usage.messages,
|
|
729
|
+
pid: info.pid,
|
|
730
|
+
};
|
|
731
|
+
result.total_cost_usd += cost;
|
|
732
|
+
result.total_tokens += result.agents[name].total_tokens;
|
|
733
|
+
} catch { /* skip agents without session data */ }
|
|
734
|
+
}
|
|
735
|
+
result.total_cost_usd = Math.round(result.total_cost_usd * 100) / 100;
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
|
|
486
739
|
function apiNotifications() {
|
|
487
740
|
return notificationHistory;
|
|
488
741
|
}
|
|
@@ -619,7 +872,7 @@ function apiExportReplay(query) {
|
|
|
619
872
|
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
620
873
|
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
621
874
|
|
|
622
|
-
const colors = ['#
|
|
875
|
+
const colors = ['#f59e0b','#f97316','#3fb950','#d29922','#f778ba','#7ee787','#e3b341','#14b8a6'];
|
|
623
876
|
const agentColors = {};
|
|
624
877
|
let colorIdx = 0;
|
|
625
878
|
for (const m of history) {
|
|
@@ -627,7 +880,7 @@ function apiExportReplay(query) {
|
|
|
627
880
|
}
|
|
628
881
|
|
|
629
882
|
const messagesJson = JSON.stringify(history.map(m => ({
|
|
630
|
-
from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#
|
|
883
|
+
from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#f59e0b'
|
|
631
884
|
})));
|
|
632
885
|
|
|
633
886
|
return `<!DOCTYPE html>
|
|
@@ -843,27 +1096,21 @@ function apiInjectMessage(body, query) {
|
|
|
843
1096
|
}
|
|
844
1097
|
|
|
845
1098
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
846
|
-
const fromName = '
|
|
1099
|
+
const fromName = '__user__';
|
|
847
1100
|
const now = new Date().toISOString();
|
|
848
1101
|
|
|
849
|
-
// Broadcast to all agents
|
|
1102
|
+
// Broadcast to all agents — single __group__ message instead of per-agent
|
|
850
1103
|
if (body.to === '__all__') {
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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 };
|
|
1104
|
+
const msg = {
|
|
1105
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
1106
|
+
from: fromName,
|
|
1107
|
+
to: '__group__',
|
|
1108
|
+
content: body.content,
|
|
1109
|
+
timestamp: now,
|
|
1110
|
+
};
|
|
1111
|
+
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
1112
|
+
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
1113
|
+
return { success: true, messageId: msg.id, broadcast: true };
|
|
867
1114
|
}
|
|
868
1115
|
|
|
869
1116
|
const msg = {
|
|
@@ -872,7 +1119,6 @@ function apiInjectMessage(body, query) {
|
|
|
872
1119
|
to: body.to,
|
|
873
1120
|
content: body.content,
|
|
874
1121
|
timestamp: now,
|
|
875
|
-
system: true,
|
|
876
1122
|
};
|
|
877
1123
|
|
|
878
1124
|
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
@@ -883,12 +1129,56 @@ function apiInjectMessage(body, query) {
|
|
|
883
1129
|
|
|
884
1130
|
// Multi-project management
|
|
885
1131
|
function apiProjects() {
|
|
886
|
-
|
|
1132
|
+
const raw = getProjects();
|
|
1133
|
+
const normalized = raw.map(p => {
|
|
1134
|
+
const np = normalizeMonitoredProjectRoot(p.path);
|
|
1135
|
+
let name = p.name;
|
|
1136
|
+
if (path.resolve(np) !== path.resolve(p.path)) {
|
|
1137
|
+
name = path.basename(np) || p.name;
|
|
1138
|
+
}
|
|
1139
|
+
if (!name || name === '.neohive') {
|
|
1140
|
+
name = path.basename(np) || 'project';
|
|
1141
|
+
}
|
|
1142
|
+
return { ...p, path: np, name };
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
const seen = new Set();
|
|
1146
|
+
const deduped = [];
|
|
1147
|
+
for (const p of normalized) {
|
|
1148
|
+
const key = path.resolve(p.path);
|
|
1149
|
+
if (seen.has(key)) continue;
|
|
1150
|
+
seen.add(key);
|
|
1151
|
+
deduped.push(p);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Drop projects whose hive is the same as “Default (local)” — avoids duplicate rows and agents flickering between two identical paths.
|
|
1155
|
+
const defaultHive = path.resolve(resolveDataDir(null));
|
|
1156
|
+
const nonRedundant = deduped.filter(p => path.resolve(resolveDataDir(p.path)) !== defaultHive);
|
|
1157
|
+
|
|
1158
|
+
const pack = (arr) =>
|
|
1159
|
+
JSON.stringify(
|
|
1160
|
+
[...arr].sort((a, b) => path.resolve(a.path).localeCompare(path.resolve(b.path)))
|
|
1161
|
+
.map(p => ({ p: path.resolve(p.path), n: p.name, a: p.added_at || '' }))
|
|
1162
|
+
);
|
|
1163
|
+
// Compare to on-disk `raw`, not `normalized`: normalized always includes dupes / default-hive
|
|
1164
|
+
// rows that nonRedundant drops, so pack(normalized) !== pack(nonRedundant) would rewrite every
|
|
1165
|
+
// read even when projects.json already matches nonRedundant.
|
|
1166
|
+
if (pack(nonRedundant) !== pack(raw)) {
|
|
1167
|
+
saveProjects(nonRedundant);
|
|
1168
|
+
}
|
|
1169
|
+
return nonRedundant;
|
|
887
1170
|
}
|
|
888
1171
|
|
|
889
1172
|
function apiAddProject(body) {
|
|
890
1173
|
if (!body.path) return { error: 'Missing "path" field' };
|
|
891
|
-
const
|
|
1174
|
+
const rawResolved = path.resolve(String(body.path).trim());
|
|
1175
|
+
if (path.basename(rawResolved) === '.neohive') {
|
|
1176
|
+
return {
|
|
1177
|
+
error:
|
|
1178
|
+
'Add the repository folder (same as your Cursor workspace root), not .neohive. Data is stored in <repo>/.neohive automatically.',
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const absPath = normalizeMonitoredProjectRoot(rawResolved);
|
|
892
1182
|
|
|
893
1183
|
// Reject root directories and system paths
|
|
894
1184
|
const normalized = absPath.replace(/\\/g, '/');
|
|
@@ -898,11 +1188,21 @@ function apiAddProject(body) {
|
|
|
898
1188
|
|
|
899
1189
|
if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
|
|
900
1190
|
|
|
901
|
-
|
|
1191
|
+
const targetHive = path.resolve(resolveDataDir(absPath));
|
|
1192
|
+
const defaultHive = path.resolve(resolveDataDir(null));
|
|
1193
|
+
if (targetHive === defaultHive) {
|
|
1194
|
+
return {
|
|
1195
|
+
error:
|
|
1196
|
+
'That folder uses the same Neohive data directory as “Default (local)”. No separate project is needed.',
|
|
1197
|
+
same_as_default: true,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
902
1200
|
|
|
903
1201
|
const projects = getProjects();
|
|
904
1202
|
const name = body.name || path.basename(absPath);
|
|
905
|
-
if (projects.find(p => p.path === absPath))
|
|
1203
|
+
if (projects.find(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) === absPath)) {
|
|
1204
|
+
return { error: 'Project already added' };
|
|
1205
|
+
}
|
|
906
1206
|
|
|
907
1207
|
// Create .neohive directory if it doesn't exist
|
|
908
1208
|
const abDir = path.join(absPath, '.neohive');
|
|
@@ -911,6 +1211,7 @@ function apiAddProject(body) {
|
|
|
911
1211
|
// Set up MCP config so agents can use it
|
|
912
1212
|
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
913
1213
|
ensureMCPConfig('claude', serverPath, absPath);
|
|
1214
|
+
ensureMCPConfig('cursor', serverPath, absPath);
|
|
914
1215
|
|
|
915
1216
|
projects.push({ name, path: absPath, added_at: new Date().toISOString() });
|
|
916
1217
|
saveProjects(projects);
|
|
@@ -919,10 +1220,10 @@ function apiAddProject(body) {
|
|
|
919
1220
|
|
|
920
1221
|
function apiRemoveProject(body) {
|
|
921
1222
|
if (!body.path) return { error: 'Missing "path" field' };
|
|
922
|
-
const absPath = path.resolve(body.path);
|
|
1223
|
+
const absPath = normalizeMonitoredProjectRoot(path.resolve(body.path));
|
|
923
1224
|
let projects = getProjects();
|
|
924
1225
|
const before = projects.length;
|
|
925
|
-
projects = projects.filter(p => p.path !== absPath);
|
|
1226
|
+
projects = projects.filter(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) !== absPath);
|
|
926
1227
|
if (projects.length === before) return { error: 'Project not found' };
|
|
927
1228
|
saveProjects(projects);
|
|
928
1229
|
return { success: true };
|
|
@@ -947,15 +1248,15 @@ function apiExportHtml(query) {
|
|
|
947
1248
|
return `<!DOCTYPE html>
|
|
948
1249
|
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
949
1250
|
<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='%
|
|
1251
|
+
<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='%23f59e0b'/><circle cx='38' cy='42' r='5' fill='%230d1117'/><circle cx='55' cy='42' r='5' fill='%230d1117'/></svg>">
|
|
951
1252
|
<style>
|
|
952
1253
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
953
1254
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e6edf3;min-height:100vh}
|
|
954
1255
|
.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,#
|
|
1256
|
+
.logo{font-size:28px;font-weight:800;background:linear-gradient(135deg,#f59e0b,#f97316);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-1px}
|
|
956
1257
|
.export-meta{margin-top:12px;display:flex;justify-content:center;gap:20px;flex-wrap:wrap}
|
|
957
1258
|
.meta-item{font-size:12px;color:#8888a0}
|
|
958
|
-
.meta-val{color:#
|
|
1259
|
+
.meta-val{color:#f59e0b;font-weight:600}
|
|
959
1260
|
.agent-chips{display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
|
960
1261
|
.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
1262
|
.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}
|
|
@@ -989,7 +1290,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
|
|
989
1290
|
.date-sep::before,.date-sep::after{content:'';flex:1;height:1px;background:#1e1e2e}
|
|
990
1291
|
.footer{border-top:1px solid #1e1e2e;padding:24px;text-align:center;font-size:11px;color:#555568}
|
|
991
1292
|
.footer a{color:#8888a0;text-decoration:none}
|
|
992
|
-
.footer a:hover{color:#
|
|
1293
|
+
.footer a:hover{color:#f59e0b}
|
|
993
1294
|
</style></head><body>
|
|
994
1295
|
<div class="export-header">
|
|
995
1296
|
<div class="logo">Neohive</div>
|
|
@@ -1004,7 +1305,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
|
|
1004
1305
|
<div class="messages" id="messages"></div>
|
|
1005
1306
|
<div class="footer">Generated by <a href="https://github.com/fakiho/neohive" target="_blank">Neohive</a> · BSL 1.1</div>
|
|
1006
1307
|
<script>
|
|
1007
|
-
var COLORS=['#
|
|
1308
|
+
var COLORS=['#f59e0b','#f97316','#3fb950','#d29922','#f85149','#f778ba','#7ee787','#e3b341','#ffa198','#14b8a6'];
|
|
1008
1309
|
var colorMap={},ci=0;
|
|
1009
1310
|
var data=${JSON.stringify(history).replace(/<\//g, '<\\/')};
|
|
1010
1311
|
function esc(t){var d=document.createElement('div');d.textContent=t;return d.innerHTML}
|
|
@@ -1194,7 +1495,7 @@ function apiDeleteRule(body, query) {
|
|
|
1194
1495
|
function apiDiscover() {
|
|
1195
1496
|
const found = [];
|
|
1196
1497
|
const checked = new Set();
|
|
1197
|
-
const existing = new Set(getProjects().map(p => p.path));
|
|
1498
|
+
const existing = new Set(getProjects().map(p => normalizeMonitoredProjectRoot(path.resolve(p.path))));
|
|
1198
1499
|
|
|
1199
1500
|
function scanDir(dir, depth, maxDepth) {
|
|
1200
1501
|
maxDepth = maxDepth || 3;
|
|
@@ -1272,13 +1573,30 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
|
|
|
1272
1573
|
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
1574
|
fs.writeFileSync(configPath, config);
|
|
1274
1575
|
}
|
|
1576
|
+
} else if (cli === 'cursor') {
|
|
1577
|
+
const cursorDir = path.join(projectDir, '.cursor');
|
|
1578
|
+
const mcpConfigPath = path.join(cursorDir, 'mcp.json');
|
|
1579
|
+
if (!fs.existsSync(cursorDir)) fs.mkdirSync(cursorDir, { recursive: true });
|
|
1580
|
+
let mcpConfig = { mcpServers: {} };
|
|
1581
|
+
if (fs.existsSync(mcpConfigPath)) {
|
|
1582
|
+
try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
|
|
1583
|
+
}
|
|
1584
|
+
if (!mcpConfig.mcpServers['neohive']) {
|
|
1585
|
+
mcpConfig.mcpServers['neohive'] = {
|
|
1586
|
+
command: 'node',
|
|
1587
|
+
args: [serverPath],
|
|
1588
|
+
env: { NEOHIVE_DATA_DIR: abDir },
|
|
1589
|
+
timeout: 300,
|
|
1590
|
+
};
|
|
1591
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
|
|
1592
|
+
}
|
|
1275
1593
|
}
|
|
1276
1594
|
}
|
|
1277
1595
|
|
|
1278
1596
|
function apiLaunchAgent(body) {
|
|
1279
1597
|
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
|
|
1598
|
+
if (!cli || !['claude', 'gemini', 'codex', 'cursor'].includes(cli)) {
|
|
1599
|
+
return { error: 'Invalid cli type. Must be: claude, gemini, codex, or cursor' };
|
|
1282
1600
|
}
|
|
1283
1601
|
if (project_dir && !validateProjectPath(project_dir)) {
|
|
1284
1602
|
return { error: 'Project directory not registered. Add it via the dashboard first.' };
|
|
@@ -1288,13 +1606,25 @@ function apiLaunchAgent(body) {
|
|
|
1288
1606
|
return { error: 'Project directory does not exist: ' + projectDir };
|
|
1289
1607
|
}
|
|
1290
1608
|
|
|
1609
|
+
const safeName = (agent_name || '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
|
|
1610
|
+
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.`);
|
|
1611
|
+
|
|
1291
1612
|
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
1292
1613
|
ensureMCPConfig(cli, serverPath, projectDir);
|
|
1293
1614
|
|
|
1615
|
+
if (cli === 'cursor') {
|
|
1616
|
+
return {
|
|
1617
|
+
success: true,
|
|
1618
|
+
launched: false,
|
|
1619
|
+
cli: 'cursor',
|
|
1620
|
+
project_dir: projectDir,
|
|
1621
|
+
prompt: launchPrompt,
|
|
1622
|
+
message: 'Open this folder in Cursor IDE. .cursor/mcp.json sets NEOHIVE_DATA_DIR to this project’s .neohive (absolute path). Restart Cursor or reload MCP tools, then paste the prompt.',
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1294
1626
|
const cliCommands = { claude: 'claude', gemini: 'gemini', codex: 'codex' };
|
|
1295
1627
|
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
1628
|
|
|
1299
1629
|
// Try to launch terminal — user pastes prompt from clipboard after CLI loads
|
|
1300
1630
|
if (process.platform === 'win32') {
|
|
@@ -1698,6 +2028,37 @@ const server = http.createServer(async (req, res) => {
|
|
|
1698
2028
|
return;
|
|
1699
2029
|
}
|
|
1700
2030
|
|
|
2031
|
+
// Health check — lightweight, no auth required
|
|
2032
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
2033
|
+
const pkg = readJson(path.join(__dirname, 'package.json')) || {};
|
|
2034
|
+
const defaultDataDir = resolveDataDir(null);
|
|
2035
|
+
const agents = readJson(filePath('agents.json', null));
|
|
2036
|
+
const agentEntries = Object.entries(agents);
|
|
2037
|
+
const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid, a.last_activity)).length;
|
|
2038
|
+
let messageCount = 0;
|
|
2039
|
+
const histFile = filePath('history.jsonl', null);
|
|
2040
|
+
if (fs.existsSync(histFile)) {
|
|
2041
|
+
try { messageCount = Math.round(fs.statSync(histFile).size / 300); } catch {}
|
|
2042
|
+
}
|
|
2043
|
+
let activeWorkflows = 0;
|
|
2044
|
+
const wfFile = filePath('workflows.json', null);
|
|
2045
|
+
if (fs.existsSync(wfFile)) {
|
|
2046
|
+
try { activeWorkflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')).filter(w => w.status === 'active').length; } catch {}
|
|
2047
|
+
}
|
|
2048
|
+
const uptimeMs = Date.now() - SERVER_START_TIME;
|
|
2049
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2050
|
+
res.end(JSON.stringify({
|
|
2051
|
+
status: 'ok',
|
|
2052
|
+
version: pkg.version || 'unknown',
|
|
2053
|
+
uptime_seconds: Math.floor(uptimeMs / 1000),
|
|
2054
|
+
agents: { alive: aliveCount, total: agentEntries.length },
|
|
2055
|
+
messages: messageCount,
|
|
2056
|
+
active_workflows: activeWorkflows,
|
|
2057
|
+
timestamp: new Date().toISOString(),
|
|
2058
|
+
}));
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
1701
2062
|
// Serve logo image
|
|
1702
2063
|
if (url.pathname === '/logo.png') {
|
|
1703
2064
|
if (fs.existsSync(LOGO_FILE)) {
|
|
@@ -1732,8 +2093,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1732
2093
|
res.end(JSON.stringify(apiHistory(url.searchParams)));
|
|
1733
2094
|
}
|
|
1734
2095
|
else if (url.pathname === '/api/agents' && req.method === 'GET') {
|
|
2096
|
+
const payload = apiAgents(url.searchParams);
|
|
1735
2097
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1736
|
-
res.end(JSON.stringify(
|
|
2098
|
+
res.end(JSON.stringify(payload));
|
|
1737
2099
|
}
|
|
1738
2100
|
else if (url.pathname === '/api/channels' && req.method === 'GET') {
|
|
1739
2101
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1916,6 +2278,60 @@ const server = http.createServer(async (req, res) => {
|
|
|
1916
2278
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1917
2279
|
res.end(JSON.stringify(apiStats(url.searchParams)));
|
|
1918
2280
|
}
|
|
2281
|
+
else if (url.pathname === '/api/token-usage' && req.method === 'GET') {
|
|
2282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2283
|
+
res.end(JSON.stringify(apiTokenUsage(url.searchParams)));
|
|
2284
|
+
}
|
|
2285
|
+
else if (url.pathname === '/api/coordinator-mode' && req.method === 'GET') {
|
|
2286
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2287
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
2288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2289
|
+
res.end(JSON.stringify({ mode: config.coordinator_mode || 'responsive' }));
|
|
2290
|
+
}
|
|
2291
|
+
else if (url.pathname === '/api/coordinator-mode' && req.method === 'POST') {
|
|
2292
|
+
try {
|
|
2293
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2294
|
+
const newMode = body.mode;
|
|
2295
|
+
if (!newMode || !['responsive', 'autonomous'].includes(newMode)) {
|
|
2296
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2297
|
+
res.end(JSON.stringify({ error: 'mode must be "responsive" or "autonomous"' }));
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2301
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2302
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
2303
|
+
const configFile = filePath('config.json', projectPath);
|
|
2304
|
+
await withFileLock(configFile, () => {
|
|
2305
|
+
const config = readJson(configFile);
|
|
2306
|
+
config.coordinator_mode = newMode;
|
|
2307
|
+
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
|
|
2308
|
+
});
|
|
2309
|
+
// Broadcast mode change to all agents + direct message to lead agents
|
|
2310
|
+
try {
|
|
2311
|
+
const messagesFile = filePath('messages.jsonl', projectPath);
|
|
2312
|
+
const historyFile = filePath('history.jsonl', projectPath);
|
|
2313
|
+
const modeText = newMode === 'responsive' ? 'Coordinator stays with human, uses consume_messages().' : 'Coordinator runs autonomously in listen() loop.';
|
|
2314
|
+
const sysMsg = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), from: '__system__', to: '__group__', content: `[MODE] Coordinator mode changed to "${newMode}". ${modeText} Coordinator: call get_guide() to update your instructions.`, timestamp: new Date().toISOString(), system: true };
|
|
2315
|
+
fs.appendFileSync(messagesFile, JSON.stringify(sysMsg) + '\n');
|
|
2316
|
+
fs.appendFileSync(historyFile, JSON.stringify(sysMsg) + '\n');
|
|
2317
|
+
// Also send direct message to lead/coordinator agents so listen() in direct mode picks it up
|
|
2318
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
2319
|
+
for (const [agentName, prof] of Object.entries(profiles)) {
|
|
2320
|
+
const role = (prof.role || '').toLowerCase();
|
|
2321
|
+
if (role === 'lead' || role === 'manager' || role === 'coordinator') {
|
|
2322
|
+
const directMsg = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), from: '__system__', to: agentName, content: `[MODE CHANGE] Coordinator mode switched to "${newMode}". ${modeText} Call get_guide() now to update your instructions.`, timestamp: new Date().toISOString(), system: true };
|
|
2323
|
+
fs.appendFileSync(messagesFile, JSON.stringify(directMsg) + '\n');
|
|
2324
|
+
fs.appendFileSync(historyFile, JSON.stringify(directMsg) + '\n');
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
} catch (e) { /* broadcast is best-effort */ }
|
|
2328
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2329
|
+
res.end(JSON.stringify({ success: true, mode: newMode }));
|
|
2330
|
+
} catch (e) {
|
|
2331
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2332
|
+
res.end(JSON.stringify({ error: 'Failed to set coordinator mode: ' + e.message }));
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
1919
2335
|
else if (url.pathname === '/api/reset' && req.method === 'POST') {
|
|
1920
2336
|
const body = await parseBody(req).catch(() => ({}));
|
|
1921
2337
|
if (!body.confirm) {
|
|
@@ -1987,6 +2403,77 @@ const server = http.createServer(async (req, res) => {
|
|
|
1987
2403
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1988
2404
|
res.end(JSON.stringify(result));
|
|
1989
2405
|
}
|
|
2406
|
+
else if (url.pathname === '/api/tasks' && req.method === 'PUT') {
|
|
2407
|
+
const body = await parseBody(req);
|
|
2408
|
+
if (!body.task_id) {
|
|
2409
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2410
|
+
res.end(JSON.stringify({ error: 'Missing task_id' }));
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2414
|
+
const tasksDir = resolveTasksWorkflowsDataDir(projectPath);
|
|
2415
|
+
const tasksFile = path.join(tasksDir, 'tasks.json');
|
|
2416
|
+
const msgDir = resolveDataDir(projectPath);
|
|
2417
|
+
let tasks = [];
|
|
2418
|
+
if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2419
|
+
const task = tasks.find(t => t.id === body.task_id);
|
|
2420
|
+
if (!task) {
|
|
2421
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2422
|
+
res.end(JSON.stringify({ error: 'Task not found' }));
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const oldAssignee = task.assignee;
|
|
2426
|
+
const msgFile = path.join(msgDir, 'messages.jsonl');
|
|
2427
|
+
const histFile = path.join(msgDir, 'history.jsonl');
|
|
2428
|
+
// Apply edits
|
|
2429
|
+
if (body.title !== undefined) task.title = body.title;
|
|
2430
|
+
if (body.description !== undefined) task.description = body.description;
|
|
2431
|
+
if (body.assignee !== undefined) task.assignee = body.assignee;
|
|
2432
|
+
task.updated_at = new Date().toISOString();
|
|
2433
|
+
fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
|
|
2434
|
+
// Notify agents on changes
|
|
2435
|
+
const writeMsg = (to, content) => {
|
|
2436
|
+
const msg = JSON.stringify({ id: 'sys_' + Date.now().toString(36) + Math.random().toString(36).slice(2,5), from: '__system__', to, content, timestamp: new Date().toISOString(), system: true }) + '\n';
|
|
2437
|
+
try { fs.appendFileSync(msgFile, msg); fs.appendFileSync(histFile, msg); } catch {}
|
|
2438
|
+
};
|
|
2439
|
+
if (body.assignee !== undefined && body.assignee !== oldAssignee) {
|
|
2440
|
+
if (body.assignee) writeMsg(body.assignee, '[TASK ASSIGNED] Task "' + task.title + '" assigned to you');
|
|
2441
|
+
if (oldAssignee) writeMsg(oldAssignee, '[TASK REASSIGNED] Task "' + task.title + '" reassigned to ' + (body.assignee || 'unassigned'));
|
|
2442
|
+
} else if (body.description !== undefined && task.assignee) {
|
|
2443
|
+
writeMsg(task.assignee, '[TASK UPDATED] Task "' + task.title + '" description updated');
|
|
2444
|
+
}
|
|
2445
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2446
|
+
res.end(JSON.stringify({ success: true, task_id: task.id }));
|
|
2447
|
+
}
|
|
2448
|
+
else if (url.pathname === '/api/tasks' && req.method === 'DELETE') {
|
|
2449
|
+
const body = await parseBody(req);
|
|
2450
|
+
if (!body.task_id) {
|
|
2451
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2452
|
+
res.end(JSON.stringify({ error: 'Missing task_id' }));
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2456
|
+
const tasksDir = resolveTasksWorkflowsDataDir(projectPath);
|
|
2457
|
+
const tasksFile = path.join(tasksDir, 'tasks.json');
|
|
2458
|
+
const msgDir = resolveDataDir(projectPath);
|
|
2459
|
+
let tasks = [];
|
|
2460
|
+
if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2461
|
+
const idx = tasks.findIndex(t => t.id === body.task_id);
|
|
2462
|
+
if (idx === -1) {
|
|
2463
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2464
|
+
res.end(JSON.stringify({ error: 'Task not found' }));
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
const removed = tasks.splice(idx, 1)[0];
|
|
2468
|
+
fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
|
|
2469
|
+
// Write system message about deletion
|
|
2470
|
+
const msgFile = path.join(msgDir, 'messages.jsonl');
|
|
2471
|
+
const histFile = path.join(msgDir, 'history.jsonl');
|
|
2472
|
+
const sysMsg = JSON.stringify({ id: 'sys_' + Date.now().toString(36), from: '__system__', to: '__all__', content: '[TASK DELETED] Task "' + (removed.title || '') + '" was removed', timestamp: new Date().toISOString(), system: true }) + '\n';
|
|
2473
|
+
try { fs.appendFileSync(msgFile, sysMsg); fs.appendFileSync(histFile, sysMsg); } catch {}
|
|
2474
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2475
|
+
res.end(JSON.stringify({ success: true, removed: removed.title }));
|
|
2476
|
+
}
|
|
1990
2477
|
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
1991
2478
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1992
2479
|
res.end(JSON.stringify(apiRules(url.searchParams)));
|
|
@@ -2137,12 +2624,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2137
2624
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2138
2625
|
res.end(JSON.stringify(result));
|
|
2139
2626
|
}
|
|
2627
|
+
else if (url.pathname === '/api/notifications' && req.method === 'GET') {
|
|
2628
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2629
|
+
const notifFile = filePath('notifications.json', projectPath);
|
|
2630
|
+
const notifs = fs.existsSync(notifFile) ? JSON.parse(fs.readFileSync(notifFile, 'utf8')) : [];
|
|
2631
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
2632
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2633
|
+
res.end(JSON.stringify(notifs.slice(-limit)));
|
|
2634
|
+
}
|
|
2140
2635
|
else if (url.pathname === '/api/workflows' && req.method === 'GET') {
|
|
2141
2636
|
const projectPath = url.searchParams.get('project') || null;
|
|
2142
2637
|
const wfFile = filePath('workflows.json', projectPath);
|
|
2143
2638
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2144
2639
|
res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
|
|
2145
2640
|
}
|
|
2641
|
+
else if (url.pathname === '/api/workflows' && req.method === 'DELETE') {
|
|
2642
|
+
const body = await parseBody(req);
|
|
2643
|
+
if (!body.workflow_id) {
|
|
2644
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2645
|
+
res.end(JSON.stringify({ error: 'Missing workflow_id' }));
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2649
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2650
|
+
let workflows = [];
|
|
2651
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2652
|
+
const idx = workflows.findIndex(w => w.id === body.workflow_id);
|
|
2653
|
+
if (idx === -1) {
|
|
2654
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2655
|
+
res.end(JSON.stringify({ error: 'Workflow not found' }));
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
const removed = workflows.splice(idx, 1)[0];
|
|
2659
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2660
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2661
|
+
res.end(JSON.stringify({ success: true, removed: removed.name }));
|
|
2662
|
+
}
|
|
2146
2663
|
else if (url.pathname === '/api/workflows' && req.method === 'POST') {
|
|
2147
2664
|
const body = await parseBody(req);
|
|
2148
2665
|
const projectPath = url.searchParams.get('project') || null;
|
|
@@ -2168,13 +2685,41 @@ const server = http.createServer(async (req, res) => {
|
|
|
2168
2685
|
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
2169
2686
|
wf.updated_at = new Date().toISOString();
|
|
2170
2687
|
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2688
|
+
} else if (body.action === 'approve' && body.workflow_id && body.step_id !== undefined) {
|
|
2689
|
+
const wf = workflows.find(w => w.id === body.workflow_id);
|
|
2690
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2691
|
+
const step = wf.steps.find(s => s.id === body.step_id);
|
|
2692
|
+
if (!step || step.status !== 'awaiting_approval') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not awaiting approval' })); return; }
|
|
2693
|
+
if (body.approved) {
|
|
2694
|
+
step.status = 'in_progress';
|
|
2695
|
+
step.started_at = new Date().toISOString();
|
|
2696
|
+
step.approved_at = new Date().toISOString();
|
|
2697
|
+
step.approved_by = '__user__';
|
|
2698
|
+
// Notify assignee via message
|
|
2699
|
+
const messagesFile = filePath('messages.jsonl', projectPath);
|
|
2700
|
+
const historyFile = filePath('history.jsonl', projectPath);
|
|
2701
|
+
const notif = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), from: '__user__', to: step.assignee || '__group__', content: `[APPROVED] Step "${step.description}" in workflow "${wf.name}" has been approved. You may proceed.`, timestamp: new Date().toISOString() };
|
|
2702
|
+
fs.appendFileSync(messagesFile, JSON.stringify(notif) + '\n');
|
|
2703
|
+
fs.appendFileSync(historyFile, JSON.stringify(notif) + '\n');
|
|
2704
|
+
} else {
|
|
2705
|
+
step.status = 'pending';
|
|
2706
|
+
step.rejected_at = new Date().toISOString();
|
|
2707
|
+
step.rejection_feedback = body.feedback || '';
|
|
2708
|
+
const messagesFile = filePath('messages.jsonl', projectPath);
|
|
2709
|
+
const historyFile = filePath('history.jsonl', projectPath);
|
|
2710
|
+
const notif = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), from: '__user__', to: step.assignee || '__group__', content: `[REJECTED] Step "${step.description}" rejected: ${body.feedback || 'No feedback'}`, timestamp: new Date().toISOString() };
|
|
2711
|
+
fs.appendFileSync(messagesFile, JSON.stringify(notif) + '\n');
|
|
2712
|
+
fs.appendFileSync(historyFile, JSON.stringify(notif) + '\n');
|
|
2713
|
+
}
|
|
2714
|
+
wf.updated_at = new Date().toISOString();
|
|
2715
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2171
2716
|
} else {
|
|
2172
2717
|
res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
|
|
2173
2718
|
}
|
|
2174
2719
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2175
2720
|
res.end(JSON.stringify({ success: true }));
|
|
2176
2721
|
}
|
|
2177
|
-
// ========== Plan Control API (
|
|
2722
|
+
// ========== Plan Control API (v6.0 Autonomy Engine) ==========
|
|
2178
2723
|
|
|
2179
2724
|
else if (url.pathname === '/api/plan/status' && req.method === 'GET') {
|
|
2180
2725
|
const projectPath = url.searchParams.get('project') || null;
|
|
@@ -2717,6 +3262,91 @@ const server = http.createServer(async (req, res) => {
|
|
|
2717
3262
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2718
3263
|
res.end(JSON.stringify(result));
|
|
2719
3264
|
}
|
|
3265
|
+
// --- Custom Templates CRUD ---
|
|
3266
|
+
else if (url.pathname === '/api/custom-templates' && req.method === 'GET') {
|
|
3267
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3268
|
+
const templates = readJson(filePath('custom-templates.json', projectPath));
|
|
3269
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3270
|
+
res.end(JSON.stringify(Array.isArray(templates) ? templates : []));
|
|
3271
|
+
}
|
|
3272
|
+
else if (url.pathname === '/api/custom-templates' && req.method === 'POST') {
|
|
3273
|
+
try {
|
|
3274
|
+
const body = await parseBody(req);
|
|
3275
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3276
|
+
const ctFile = filePath('custom-templates.json', projectPath);
|
|
3277
|
+
const dataDir = resolveDataDir(projectPath);
|
|
3278
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
3279
|
+
await withFileLock(ctFile, () => {
|
|
3280
|
+
const templates = readJson(ctFile);
|
|
3281
|
+
const list = Array.isArray(templates) ? templates : [];
|
|
3282
|
+
const id = body.id || ('custom-' + (body.name || 'template').toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30) + '-' + Date.now().toString(36).slice(-4));
|
|
3283
|
+
if (list.find(t => t.id === id)) {
|
|
3284
|
+
throw new Error('Template with this ID already exists. Use PUT to update.');
|
|
3285
|
+
}
|
|
3286
|
+
const template = {
|
|
3287
|
+
id,
|
|
3288
|
+
name: (body.name || 'Custom Template').substring(0, 100),
|
|
3289
|
+
description: (body.description || '').substring(0, 500),
|
|
3290
|
+
category: body.category || 'custom',
|
|
3291
|
+
conversation_mode: body.conversation_mode || 'direct',
|
|
3292
|
+
source: 'custom',
|
|
3293
|
+
created_at: new Date().toISOString(),
|
|
3294
|
+
updated_at: new Date().toISOString(),
|
|
3295
|
+
agents: Array.isArray(body.agents) ? body.agents.slice(0, 10) : [],
|
|
3296
|
+
...(body.workflow && { workflow: body.workflow }),
|
|
3297
|
+
};
|
|
3298
|
+
list.push(template);
|
|
3299
|
+
fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
|
|
3300
|
+
});
|
|
3301
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3302
|
+
res.end(JSON.stringify({ success: true }));
|
|
3303
|
+
} catch (e) {
|
|
3304
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3305
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
else if (url.pathname === '/api/custom-templates' && req.method === 'PUT') {
|
|
3309
|
+
try {
|
|
3310
|
+
const body = await parseBody(req);
|
|
3311
|
+
if (!body.id) throw new Error('id required');
|
|
3312
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3313
|
+
const ctFile = filePath('custom-templates.json', projectPath);
|
|
3314
|
+
await withFileLock(ctFile, () => {
|
|
3315
|
+
const list = readJson(ctFile);
|
|
3316
|
+
if (!Array.isArray(list)) throw new Error('No custom templates found');
|
|
3317
|
+
const idx = list.findIndex(t => t.id === body.id);
|
|
3318
|
+
if (idx === -1) throw new Error('Template not found: ' + body.id);
|
|
3319
|
+
list[idx] = { ...list[idx], ...body, updated_at: new Date().toISOString(), source: 'custom' };
|
|
3320
|
+
fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
|
|
3321
|
+
});
|
|
3322
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3323
|
+
res.end(JSON.stringify({ success: true }));
|
|
3324
|
+
} catch (e) {
|
|
3325
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3326
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
else if (url.pathname === '/api/custom-templates' && req.method === 'DELETE') {
|
|
3330
|
+
try {
|
|
3331
|
+
const body = await parseBody(req);
|
|
3332
|
+
if (!body.id) throw new Error('id required');
|
|
3333
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3334
|
+
const ctFile = filePath('custom-templates.json', projectPath);
|
|
3335
|
+
await withFileLock(ctFile, () => {
|
|
3336
|
+
const list = readJson(ctFile);
|
|
3337
|
+
if (!Array.isArray(list)) throw new Error('No custom templates found');
|
|
3338
|
+
const idx = list.findIndex(t => t.id === body.id);
|
|
3339
|
+
if (idx === -1) throw new Error('Template not found: ' + body.id);
|
|
3340
|
+
list.splice(idx, 1);
|
|
3341
|
+
fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
|
|
3342
|
+
});
|
|
3343
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3344
|
+
res.end(JSON.stringify({ success: true }));
|
|
3345
|
+
} catch (e) {
|
|
3346
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3347
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
2720
3350
|
// --- v3.4: Agent Permissions ---
|
|
2721
3351
|
else if (url.pathname === '/api/permissions' && req.method === 'GET') {
|
|
2722
3352
|
const projectPath = url.searchParams.get('project') || null;
|
|
@@ -2773,14 +3403,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
2773
3403
|
}
|
|
2774
3404
|
// Templates API
|
|
2775
3405
|
else if (url.pathname === '/api/templates' && req.method === 'GET') {
|
|
2776
|
-
const templatesDir = path.join(__dirname, 'templates');
|
|
2777
3406
|
let templates = [];
|
|
3407
|
+
const templatesDir = path.join(__dirname, 'templates');
|
|
2778
3408
|
if (fs.existsSync(templatesDir)) {
|
|
2779
3409
|
templates = fs.readdirSync(templatesDir)
|
|
2780
3410
|
.filter(f => f.endsWith('.json'))
|
|
2781
|
-
.map(f => { try {
|
|
3411
|
+
.map(f => { try { const t = JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); t.source = 'templates'; return t; } catch { return null; } })
|
|
2782
3412
|
.filter(Boolean);
|
|
2783
3413
|
}
|
|
3414
|
+
const convDir = path.join(__dirname, 'conversation-templates');
|
|
3415
|
+
if (fs.existsSync(convDir)) {
|
|
3416
|
+
const conv = fs.readdirSync(convDir)
|
|
3417
|
+
.filter(f => f.endsWith('.json'))
|
|
3418
|
+
.map(f => { try { const t = JSON.parse(fs.readFileSync(path.join(convDir, f), 'utf8')); t.source = 'conversation-templates'; return t; } catch { return null; } })
|
|
3419
|
+
.filter(Boolean);
|
|
3420
|
+
templates = templates.concat(conv);
|
|
3421
|
+
}
|
|
3422
|
+
// Merge custom templates from project data dir
|
|
3423
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3424
|
+
const customTemplates = readJson(filePath('custom-templates.json', projectPath));
|
|
3425
|
+
if (Array.isArray(customTemplates) && customTemplates.length > 0) {
|
|
3426
|
+
templates = templates.concat(customTemplates);
|
|
3427
|
+
}
|
|
2784
3428
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2785
3429
|
res.end(JSON.stringify(templates));
|
|
2786
3430
|
}
|
|
@@ -2948,14 +3592,22 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
|
2948
3592
|
const dataDir = resolveDataDir();
|
|
2949
3593
|
const lanIP = getLanIP();
|
|
2950
3594
|
console.log('');
|
|
2951
|
-
console.log(' Neohive
|
|
3595
|
+
console.log(' Neohive Dashboard v6.0.0');
|
|
2952
3596
|
console.log(' ============================================');
|
|
2953
3597
|
console.log(' Dashboard: http://localhost:' + PORT);
|
|
2954
3598
|
if (LAN_MODE && lanIP) {
|
|
2955
3599
|
console.log(' LAN access: http://' + lanIP + ':' + PORT);
|
|
2956
3600
|
console.log(' WARNING: LAN mode enabled — accessible to anyone on your network');
|
|
2957
3601
|
}
|
|
2958
|
-
|
|
3602
|
+
let dataDirLine = ' Data dir: ' + dataDir;
|
|
3603
|
+
if (_defaultDataResolved.source === 'walk-up') {
|
|
3604
|
+
dataDirLine += ' (best .neohive among ancestors — tasks/agents/history)';
|
|
3605
|
+
} else if (_defaultDataResolved.source === 'mcp-config' && _defaultDataResolved.configAt) {
|
|
3606
|
+
dataDirLine += ' (from MCP config under ' + _defaultDataResolved.configAt + ')';
|
|
3607
|
+
} else if (_defaultDataResolved.source === 'environment') {
|
|
3608
|
+
dataDirLine += ' (NEOHIVE_DATA_DIR / NEOHIVE_DATA)';
|
|
3609
|
+
}
|
|
3610
|
+
console.log(dataDirLine);
|
|
2959
3611
|
console.log(' Projects: ' + getProjects().length + ' registered');
|
|
2960
3612
|
console.log(' Updates: SSE (real-time) + polling fallback (2s)');
|
|
2961
3613
|
console.log('');
|