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/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
- const DEFAULT_DATA_DIR = process.env.NEOHIVE_DATA || path.join(process.cwd(), '.neohive');
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
- // 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; }
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
- const dir = path.join(projectPath, '.neohive');
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
- return path.join(resolveDataDir(projectPath), name);
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 = 60000; // 60sif heartbeat updated within this, agent is alive
303
+ const STALE_THRESHOLD = 30000; // 30s3x 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='%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",
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='%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",
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='%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",
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='%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",
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); // 'heartbeat-Backend.json' → 'Backend'
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 ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
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 > 60;
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 = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
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] || '#58a6ff'
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 = 'Dashboard';
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 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 };
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
- return getProjects();
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 absPath = path.resolve(body.path);
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
- // Any existing directory can be added as a project — user explicitly chose it
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)) return { error: 'Project already added' };
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='%2358a6ff'/><circle cx='38' cy='42' r='5' fill='%230d1117'/><circle cx='55' cy='42' r='5' fill='%230d1117'/></svg>">
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,#58a6ff,#bc8cff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-1px}
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:#58a6ff;font-weight:600}
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:#58a6ff}
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> &middot; BSL 1.1</div>
1006
1307
  <script>
1007
- var COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#f778ba','#79c0ff','#7ee787','#e3b341','#ffa198'];
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 codex' };
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(apiAgents(url.searchParams)));
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 (v5.0 Autonomy Engine) ==========
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 { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
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 - Neohive Dashboard v3.5.1');
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
- console.log(' Data dir: ' + dataDir);
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('');