neohive 6.0.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -4,6 +4,34 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const { spawn } = require('child_process');
7
+ const { upsertNeohiveMcpInToml } = require('./lib/codex-neohive-toml');
8
+ const { readIdeActivity, applyIdeActivityHint } = require('./lib/ide-activity');
9
+ const _audit = require('./lib/audit');
10
+
11
+ function findCursorProjectRootWithNeohive(startDir) {
12
+ let dir = path.resolve(startDir);
13
+ const root = path.parse(dir).root;
14
+ while (true) {
15
+ const mcpPath = path.join(dir, '.cursor', 'mcp.json');
16
+ if (fs.existsSync(mcpPath)) {
17
+ try {
18
+ const j = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
19
+ if (j.mcpServers && j.mcpServers.neohive) return dir;
20
+ } catch {}
21
+ }
22
+ if (dir === root) break;
23
+ dir = path.dirname(dir);
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function normalizeNeohiveDataDirString(raw, workspaceRoot) {
29
+ if (raw == null || typeof raw !== 'string') return null;
30
+ let d = raw.trim();
31
+ if (!d) return null;
32
+ d = d.replace(/\$\{workspaceFolder\}/gi, workspaceRoot);
33
+ return path.isAbsolute(d) ? path.resolve(d) : path.resolve(workspaceRoot, d);
34
+ }
7
35
 
8
36
  // --- File-level mutex for serializing read-then-write operations ---
9
37
  const lockMap = new Map();
@@ -15,6 +43,7 @@ function withFileLock(filePath, fn) {
15
43
  }
16
44
 
17
45
  const PORT = parseInt(process.env.NEOHIVE_PORT || '3000', 10);
46
+ const SERVER_START_TIME = Date.now();
18
47
  const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
19
48
  let LAN_MODE = process.env.NEOHIVE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
20
49
 
@@ -56,9 +85,130 @@ function getLanIP() {
56
85
  }
57
86
  return fallback;
58
87
  }
59
- const DEFAULT_DATA_DIR = process.env.NEOHIVE_DATA || path.join(process.cwd(), '.neohive');
88
+
89
+ // Check if a directory has actual data files (not just an empty dir)
90
+ function hasDataFiles(dir) {
91
+ if (!fs.existsSync(dir)) return false;
92
+ try {
93
+ const files = fs.readdirSync(dir);
94
+ return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
95
+ } catch { return false; }
96
+ }
97
+
98
+ function countAgentsInNeohiveDir(nhDir) {
99
+ if (!fs.existsSync(nhDir)) return 0;
100
+ const ag = path.join(nhDir, 'agents.json');
101
+ if (!fs.existsSync(ag)) return 0;
102
+ try {
103
+ const j = JSON.parse(fs.readFileSync(ag, 'utf8'));
104
+ return j && typeof j === 'object' ? Object.keys(j).length : 0;
105
+ } catch { return 0; }
106
+ }
107
+
108
+ function countNeohiveJsonArray(filePath) {
109
+ if (!fs.existsSync(filePath)) return 0;
110
+ try {
111
+ const j = JSON.parse(fs.readFileSync(filePath, 'utf8'));
112
+ return Array.isArray(j) ? j.length : 0;
113
+ } catch { return 0; }
114
+ }
115
+
116
+ function neohiveHasTasksOrWorkflows(nhDir) {
117
+ return countNeohiveJsonArray(path.join(nhDir, 'tasks.json')) > 0
118
+ || countNeohiveJsonArray(path.join(nhDir, 'workflows.json')) > 0;
119
+ }
120
+
121
+ // Score each ancestor’s .neohive so we prefer the hive that has tasks/workflows (not the first with only agents).
122
+ function scoreNeohiveDataDir(nhDir) {
123
+ if (!fs.existsSync(nhDir)) return -1;
124
+ let s = countAgentsInNeohiveDir(nhDir) * 10;
125
+ s += countNeohiveJsonArray(path.join(nhDir, 'tasks.json'));
126
+ s += countNeohiveJsonArray(path.join(nhDir, 'workflows.json')) * 3;
127
+ if (hasDataFiles(nhDir)) s += 5;
128
+ return s;
129
+ }
130
+
131
+ function bestNeohiveAmongAncestors(startDir) {
132
+ let dir = path.resolve(startDir);
133
+ const root = path.parse(dir).root;
134
+ let best = null;
135
+ let bestScore = -1;
136
+ for (let d = 0; d < 24 && dir !== root; d++) {
137
+ const nh = path.join(dir, '.neohive');
138
+ const sc = scoreNeohiveDataDir(nh);
139
+ if (sc > bestScore) {
140
+ bestScore = sc;
141
+ best = nh;
142
+ }
143
+ dir = path.dirname(dir);
144
+ }
145
+ if (bestScore <= 0) return null;
146
+ return best;
147
+ }
148
+
149
+ // Read NEOHIVE_DATA_DIR from project-local MCP configs (same files init writes).
150
+ // Cursor uses ${workspaceFolder} in .cursor/mcp.json — expand using projectRoot when parsing files.
151
+ function readNeohiveDataDirFromMcpConfigs(projectRoot) {
152
+ const candidates = [
153
+ path.join(projectRoot, '.cursor', 'mcp.json'),
154
+ path.join(projectRoot, '.mcp.json'),
155
+ path.join(projectRoot, '.gemini', 'settings.json'),
156
+ ];
157
+ for (const filePath of candidates) {
158
+ if (!fs.existsSync(filePath)) continue;
159
+ try {
160
+ const j = JSON.parse(fs.readFileSync(filePath, 'utf8'));
161
+ const nh = j.mcpServers && j.mcpServers.neohive;
162
+ const raw = nh && nh.env && nh.env.NEOHIVE_DATA_DIR;
163
+ const out = normalizeNeohiveDataDirString(raw, projectRoot);
164
+ if (out) return out;
165
+ } catch {}
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function resolveDashboardDefaultDataDir() {
171
+ let envData = process.env.NEOHIVE_DATA_DIR || process.env.NEOHIVE_DATA;
172
+ if (envData && String(envData).trim()) {
173
+ let s = String(envData).trim();
174
+ if (/\$\{workspaceFolder\}/i.test(s)) {
175
+ const root = findCursorProjectRootWithNeohive(process.cwd());
176
+ if (root) s = s.replace(/\$\{workspaceFolder\}/gi, root);
177
+ }
178
+ return { path: path.resolve(s), source: 'environment' };
179
+ }
180
+ const fromWalk = bestNeohiveAmongAncestors(process.cwd());
181
+ if (fromWalk) {
182
+ return { path: fromWalk, source: 'walk-up' };
183
+ }
184
+ let dir = path.resolve(process.cwd());
185
+ const root = path.parse(dir).root;
186
+ while (true) {
187
+ const fromMcp = readNeohiveDataDirFromMcpConfigs(dir);
188
+ if (fromMcp) {
189
+ return { path: fromMcp, source: 'mcp-config', configAt: dir };
190
+ }
191
+ if (dir === root) break;
192
+ dir = path.dirname(dir);
193
+ }
194
+ return { path: path.join(process.cwd(), '.neohive'), source: 'cwd' };
195
+ }
196
+
197
+ const _defaultDataResolved = resolveDashboardDefaultDataDir();
198
+ const DEFAULT_DATA_DIR = _defaultDataResolved.path;
199
+
200
+ // Auto-migrate from .agent-bridge/ to .neohive/ (v5 → v6 rename)
201
+ const _legacyDir = path.join(path.dirname(DEFAULT_DATA_DIR), '.agent-bridge');
202
+ if (!fs.existsSync(DEFAULT_DATA_DIR) && fs.existsSync(_legacyDir)) {
203
+ try { fs.renameSync(_legacyDir, DEFAULT_DATA_DIR); } catch {}
204
+ }
205
+
60
206
  const HTML_FILE = path.join(__dirname, 'dashboard.html');
61
- const LOGO_FILE = path.join(__dirname, 'logo.png');
207
+ const DESIGN_SYSTEM_CSS = path.join(__dirname, 'design-system.css');
208
+ const DESIGN_SYSTEM_HTML = path.join(__dirname, 'design-system.html');
209
+ const LOGO_FILE = path.join(__dirname, 'logo.svg');
210
+ const LOGO_SVG_FILE = path.join(__dirname, 'logo.svg');
211
+ const FAVICON_FILE = path.join(__dirname, 'favicon.png');
62
212
  const PROJECTS_FILE = path.join(__dirname, 'projects.json');
63
213
 
64
214
  // --- Multi-project support ---
@@ -69,25 +219,32 @@ function getProjects() {
69
219
  }
70
220
 
71
221
  function saveProjects(projects) {
72
- fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
222
+ try {
223
+ fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
224
+ } catch (e) {
225
+ console.error('[saveProjects] Failed to write projects file:', e.message);
226
+ throw new Error('Failed to save projects: ' + e.message);
227
+ }
73
228
  }
74
229
 
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; }
230
+ // Multi-project paths must be the repo root, not .../project/.neohive (otherwise we join .neohive twice).
231
+ function normalizeMonitoredProjectRoot(projectPath) {
232
+ if (!projectPath) return projectPath;
233
+ const p = path.resolve(projectPath);
234
+ if (path.basename(p) === '.neohive') {
235
+ return path.dirname(p);
236
+ }
237
+ return p;
82
238
  }
83
239
 
84
240
  // Resolve data dir: explicit project path > env var > cwd > legacy fallback
85
241
  // Prefers directories with actual data files over empty ones
86
242
  function resolveDataDir(projectPath) {
87
243
  if (projectPath) {
88
- const dir = path.join(projectPath, '.neohive');
244
+ projectPath = normalizeMonitoredProjectRoot(projectPath);
245
+ let dir = path.join(projectPath, '.neohive');
89
246
  const dataDir = path.join(projectPath, 'data');
90
- // Prefer whichever has data
247
+ // Prefer whichever has data (local hive only — do not redirect agents/messages to parent)
91
248
  if (hasDataFiles(dir)) return dir;
92
249
  if (hasDataFiles(dataDir)) return dataDir;
93
250
  if (fs.existsSync(dir)) return dir;
@@ -103,19 +260,35 @@ function resolveDataDir(projectPath) {
103
260
  return DEFAULT_DATA_DIR;
104
261
  }
105
262
 
263
+ // Monorepo: tasks/workflows may live in parent .neohive while agents.json stays in the subfolder.
264
+ // Using parent for *all* files hid agents (empty parent agents.json). Only tasks + workflows use this.
265
+ function resolveTasksWorkflowsDataDir(projectPath) {
266
+ if (!projectPath) return resolveDataDir(null);
267
+ projectPath = normalizeMonitoredProjectRoot(projectPath);
268
+ const localHive = path.join(projectPath, '.neohive');
269
+ const parentHive = path.join(path.dirname(projectPath), '.neohive');
270
+ if (!neohiveHasTasksOrWorkflows(localHive) && neohiveHasTasksOrWorkflows(parentHive)) {
271
+ return parentHive;
272
+ }
273
+ return resolveDataDir(projectPath);
274
+ }
275
+
106
276
  function filePath(name, projectPath) {
107
- return path.join(resolveDataDir(projectPath), name);
277
+ const dir = (name === 'tasks.json' || name === 'workflows.json')
278
+ ? resolveTasksWorkflowsDataDir(projectPath)
279
+ : resolveDataDir(projectPath);
280
+ return path.join(dir, name);
108
281
  }
109
282
 
110
283
  // Validate project path is registered or is the default
111
284
  function validateProjectPath(projectPath) {
112
285
  if (!projectPath) return true;
113
- const absPath = path.resolve(projectPath);
286
+ const absPath = normalizeMonitoredProjectRoot(path.resolve(projectPath));
114
287
  const projects = getProjects();
115
288
  const cwd = path.resolve(process.cwd());
116
289
  const scriptDir = path.resolve(__dirname);
117
290
  if (absPath === cwd || absPath === scriptDir) return true;
118
- return projects.some(p => path.resolve(p.path) === absPath);
291
+ return projects.some(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) === absPath);
119
292
  }
120
293
 
121
294
  function htmlEscape(s) {
@@ -139,7 +312,7 @@ function readJson(file) {
139
312
  }
140
313
 
141
314
  function isPidAlive(pid, lastActivity) {
142
- const STALE_THRESHOLD = 60000; // 60sif heartbeat updated within this, agent is alive
315
+ const STALE_THRESHOLD = 30000; // 30s3x heartbeat interval, catches dead agents faster
143
316
 
144
317
  // PRIORITY 1: Trust heartbeat freshness over PID status
145
318
  // Heartbeats are written by the actual running process — if fresh, agent is alive
@@ -160,18 +333,18 @@ function isPidAlive(pid, lastActivity) {
160
333
 
161
334
  // --- Default avatar helpers ---
162
335
  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",
336
+ "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
337
  "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
338
  "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
339
  "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",
340
+ "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
341
  "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",
342
+ "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
343
  "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
344
  "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
345
  "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",
346
+ "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",
347
+ "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
348
  ];
176
349
 
177
350
  function hashName(name) {
@@ -268,24 +441,25 @@ function apiAgents(query) {
268
441
  const projectPath = query.get('project') || null;
269
442
  const agents = readJson(filePath('agents.json', projectPath));
270
443
  const profiles = readJson(filePath('profiles.json', projectPath));
444
+ const cards = readJson(filePath('agent-cards.json', projectPath));
271
445
  const history = readJsonl(filePath('history.jsonl', projectPath));
272
446
 
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
447
  const dataDir = resolveDataDir(projectPath);
276
448
  try {
277
449
  const hbFiles = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-') && f.endsWith('.json'));
278
450
  for (const f of hbFiles) {
279
- const name = f.slice(10, -5); // 'heartbeat-Backend.json' → 'Backend'
451
+ const name = f.slice(10, -5);
280
452
  if (agents[name]) {
281
453
  try {
282
454
  const hb = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
283
455
  if (hb.last_activity) agents[name].last_activity = hb.last_activity;
284
456
  if (hb.pid) agents[name].pid = hb.pid;
457
+ if (hb.ppid) agents[name].ppid = hb.ppid;
285
458
  } catch {}
286
459
  }
287
460
  }
288
461
  } catch {}
462
+
289
463
  const result = {};
290
464
 
291
465
  // Build last message timestamp per agent from history
@@ -298,16 +472,46 @@ function apiAgents(query) {
298
472
  const alive = isPidAlive(info.pid, info.last_activity);
299
473
  const lastActivity = info.last_activity || info.timestamp;
300
474
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
475
+ const hasHeartbeat = fs.existsSync(path.join(resolveDataDir(projectPath), `heartbeat-${name}.json`));
301
476
  const profile = profiles[name] || {};
302
477
  const isLocal = (() => { try { process.kill(info.pid, 0); return true; } catch { return false; } })();
478
+
479
+ let status;
480
+ if (alive) {
481
+ if (info.listening_since) {
482
+ status = 'listening';
483
+ } else {
484
+ // Detect stuck/unresponsive: agent is alive but hasn't called listen() recently
485
+ const lastListened = info.last_listened_at;
486
+ const sinceLastListen = lastListened ? Math.floor((Date.now() - new Date(lastListened).getTime()) / 1000) : Infinity;
487
+ if (sinceLastListen > 600) {
488
+ status = 'stuck'; // > 10 minutes without listen() call
489
+ } else if (sinceLastListen > 120) {
490
+ status = 'unresponsive'; // > 2 minutes without listen() call
491
+ } else if (idleSeconds > 30) {
492
+ status = 'idle';
493
+ } else {
494
+ status = 'working';
495
+ }
496
+ }
497
+ } else if (!hasHeartbeat) {
498
+ status = 'unknown';
499
+ } else if (idleSeconds <= 120) {
500
+ status = 'stale';
501
+ } else {
502
+ status = 'offline';
503
+ }
504
+
303
505
  result[name] = {
304
506
  pid: info.pid,
507
+ ppid: info.ppid || null,
305
508
  alive,
306
509
  registered_at: info.timestamp,
307
510
  last_activity: lastActivity,
308
511
  last_message: lastMessageTime[name] || null,
309
512
  idle_seconds: alive ? idleSeconds : null,
310
- status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
513
+ last_listened_at: info.last_listened_at || null,
514
+ status,
311
515
  listening_since: info.listening_since || null,
312
516
  is_listening: !!(info.listening_since && alive),
313
517
  provider: info.provider || 'unknown',
@@ -319,6 +523,8 @@ function apiAgents(query) {
319
523
  appearance: profile.appearance || {},
320
524
  hostname: info.hostname || null,
321
525
  is_remote: !isLocal && alive,
526
+ platform_skills: (cards && cards[name] && cards[name].platform_skills) || [],
527
+ skills: (cards && cards[name] && cards[name].skills) || [],
322
528
  };
323
529
  // Include workspace status for agent intent board
324
530
  try {
@@ -328,6 +534,10 @@ function apiAgents(query) {
328
534
  if (ws._status) result[name].current_status = ws._status;
329
535
  }
330
536
  } catch {}
537
+
538
+ const dataDir = resolveDataDir(projectPath);
539
+ const ide = readIdeActivity(dataDir, name);
540
+ if (ide) applyIdeActivityHint(result[name], ide, { dataDir, agentName: name });
331
541
  }
332
542
  return result;
333
543
  }
@@ -345,7 +555,7 @@ function apiStatus(query) {
345
555
  if (!isPidAlive(a.pid, a.last_activity)) return false;
346
556
  const lastActivity = a.last_activity || a.timestamp;
347
557
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
348
- return idleSeconds > 60;
558
+ return idleSeconds > 30;
349
559
  }).length;
350
560
 
351
561
  // Include managed mode status if active
@@ -357,6 +567,7 @@ function apiStatus(query) {
357
567
  sleepingCount,
358
568
  threadCount: threads.size,
359
569
  conversation_mode: config.conversation_mode || 'direct',
570
+ coordinator_mode: config.coordinator_mode || 'responsive',
360
571
  };
361
572
 
362
573
  if (config.conversation_mode === 'managed' && config.managed) {
@@ -483,6 +694,210 @@ function generateNotifications(currentAgents) {
483
694
  }
484
695
  }
485
696
 
697
+ // --- Token Usage Tracking ---
698
+
699
+ // Walk the process tree upward from startPid, returning the first PID
700
+ // that has a session file in sessionsDir. At each level also checks
701
+ // sibling processes (children of the same parent) to handle the VS Code
702
+ // MCP topology where the claude binary and MCP server share a parent.
703
+ function findSessionPidInTree(startPid, sessionsDir, maxDepth = 5) {
704
+ const { execSync } = require('child_process');
705
+ const getParent = (pid) => {
706
+ try {
707
+ const s = execSync(`ps -o ppid= -p ${pid} 2>/dev/null`, { timeout: 1000 }).toString().trim();
708
+ const n = parseInt(s, 10);
709
+ return (n && n !== pid) ? n : null;
710
+ } catch { return null; }
711
+ };
712
+ const getSiblings = (parentPid) => {
713
+ try {
714
+ return execSync(`pgrep -P ${parentPid} 2>/dev/null`, { timeout: 1000 })
715
+ .toString().trim().split('\n').map(s => parseInt(s, 10)).filter(Boolean);
716
+ } catch { return []; }
717
+ };
718
+
719
+ let pid = startPid;
720
+ for (let i = 0; i < maxDepth; i++) {
721
+ if (!pid || pid <= 1) break;
722
+ // Check this pid directly
723
+ if (fs.existsSync(path.join(sessionsDir, pid + '.json'))) return pid;
724
+ const parent = getParent(pid);
725
+ if (!parent) break;
726
+ // Check siblings (handles VS Code: MCP server and claude binary share same parent)
727
+ for (const sibling of getSiblings(parent)) {
728
+ if (sibling === pid) continue;
729
+ if (fs.existsSync(path.join(sessionsDir, sibling + '.json'))) return sibling;
730
+ }
731
+ pid = parent;
732
+ }
733
+ return null;
734
+ }
735
+
736
+ // Pricing per 1M tokens (USD)
737
+ const TOKEN_PRICING = {
738
+ 'claude-opus-4-6': { input: 15.00, output: 75.00, cache_write: 18.75, cache_read: 1.50 },
739
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
740
+ 'claude-haiku-4-5': { input: 0.80, output: 4.00, cache_write: 1.00, cache_read: 0.08 },
741
+ };
742
+
743
+ function parseSessionUsage(sessionFile, maxBytes) {
744
+ const usage = { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, messages: 0, model: null };
745
+ try {
746
+ const stat = fs.statSync(sessionFile);
747
+ // For huge files, only read the last portion to avoid memory issues
748
+ const readSize = Math.min(stat.size, maxBytes || 5 * 1024 * 1024); // 5MB max
749
+ const fd = fs.openSync(sessionFile, 'r');
750
+ const buf = Buffer.alloc(readSize);
751
+ fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
752
+ fs.closeSync(fd);
753
+ const content = buf.toString('utf8');
754
+ // Find complete lines (skip partial first line if we started mid-file)
755
+ const lines = content.split('\n');
756
+ if (stat.size > readSize) lines.shift(); // skip potentially partial first line
757
+ for (const line of lines) {
758
+ if (!line.trim() || !line.includes('"usage"')) continue;
759
+ try {
760
+ const entry = JSON.parse(line);
761
+ if (entry.type === 'assistant' && entry.message && entry.message.usage) {
762
+ const u = entry.message.usage;
763
+ usage.input_tokens += u.input_tokens || 0;
764
+ usage.output_tokens += u.output_tokens || 0;
765
+ usage.cache_creation_tokens += u.cache_creation_input_tokens || 0;
766
+ usage.cache_read_tokens += u.cache_read_input_tokens || 0;
767
+ usage.messages++;
768
+ if (entry.message.model) usage.model = entry.message.model;
769
+ }
770
+ } catch { /* skip unparseable lines */ }
771
+ }
772
+ } catch (e) { /* session file unreadable */ }
773
+ return usage;
774
+ }
775
+
776
+ /**
777
+ * Score all .jsonl session files in the project dir by birthtime proximity to
778
+ * an agent's started_at. Returns sorted array of { file, delta } (ascending).
779
+ */
780
+ function scoreSessionsByProximity(projectSessionDir, agentStartedAt) {
781
+ if (!agentStartedAt || !fs.existsSync(projectSessionDir)) return [];
782
+ const agentTs = new Date(agentStartedAt).getTime();
783
+ if (isNaN(agentTs)) return [];
784
+
785
+ const scored = [];
786
+ try {
787
+ const files = fs.readdirSync(projectSessionDir).filter(f => f.endsWith('.jsonl'));
788
+ for (const f of files) {
789
+ const fp = path.join(projectSessionDir, f);
790
+ try {
791
+ const stat = fs.statSync(fp);
792
+ scored.push({ file: fp, delta: Math.abs(stat.birthtimeMs - agentTs) });
793
+ } catch { /* skip unreadable */ }
794
+ }
795
+ } catch { return []; }
796
+ scored.sort((a, b) => a.delta - b.delta);
797
+ return scored;
798
+ }
799
+
800
+ function apiTokenUsage(query) {
801
+ const projectPath = query.get('project') || null;
802
+ const dataDir = resolveDataDir(projectPath);
803
+ const agents = readJson(filePath('agents.json', projectPath));
804
+ const home = os.homedir();
805
+ const sessionsDir = path.join(home, '.claude', 'sessions');
806
+ const projectAbsPath = projectPath ? path.resolve(projectPath) : path.resolve(process.cwd());
807
+ const projectSlug = projectAbsPath.replace(/\//g, '-');
808
+ const projectSessionDir = path.join(home, '.claude', 'projects', projectSlug);
809
+
810
+ const result = { agents: {}, total_cost_usd: 0, total_tokens: 0 };
811
+
812
+ const agentSessions = {};
813
+ const claimedFiles = new Set();
814
+
815
+ for (const [name, info] of Object.entries(agents)) {
816
+ if (!info.pid) continue;
817
+ try {
818
+ // Priority 0: direct session ID from env var (written to agents.json + heartbeat)
819
+ const sessionId = info.claude_session_id || (() => {
820
+ try {
821
+ const hb = JSON.parse(fs.readFileSync(path.join(dataDir, `heartbeat-${name}.json`), 'utf8'));
822
+ return hb.claude_session_id || null;
823
+ } catch { return null; }
824
+ })();
825
+ if (sessionId) {
826
+ const candidate = path.join(projectSessionDir, sessionId + '.jsonl');
827
+ if (fs.existsSync(candidate)) {
828
+ agentSessions[name] = candidate;
829
+ claimedFiles.add(candidate);
830
+ continue;
831
+ }
832
+ }
833
+
834
+ // Priority 1: process-tree lookup
835
+ const cliPid = findSessionPidInTree(info.pid, sessionsDir) ||
836
+ (info.ppid ? findSessionPidInTree(info.ppid, sessionsDir) : null);
837
+ if (cliPid) {
838
+ const pidFile = path.join(sessionsDir, cliPid + '.json');
839
+ const session = readJson(pidFile);
840
+ if (session && session.sessionId) {
841
+ const candidate = path.join(projectSessionDir, session.sessionId + '.jsonl');
842
+ if (fs.existsSync(candidate)) {
843
+ agentSessions[name] = candidate;
844
+ claimedFiles.add(candidate);
845
+ }
846
+ }
847
+ }
848
+ } catch { /* skip */ }
849
+ }
850
+
851
+ // Phase 2: fallback — greedy assignment by birthtime proximity (closest-first wins)
852
+ const needFallback = Object.entries(agents)
853
+ .filter(([name, info]) => info.pid && !agentSessions[name])
854
+ .map(([name, info]) => {
855
+ const scored = scoreSessionsByProximity(projectSessionDir, info.started_at || info.timestamp);
856
+ return { name, scored };
857
+ })
858
+ .sort((a, b) => {
859
+ const aMin = a.scored.length ? a.scored[0].delta : Infinity;
860
+ const bMin = b.scored.length ? b.scored[0].delta : Infinity;
861
+ return aMin - bMin;
862
+ });
863
+
864
+ for (const { name, scored } of needFallback) {
865
+ for (const { file } of scored) {
866
+ if (!claimedFiles.has(file)) {
867
+ agentSessions[name] = file;
868
+ claimedFiles.add(file);
869
+ break;
870
+ }
871
+ }
872
+ }
873
+
874
+ // Phase 3: compute usage + cost
875
+ for (const [name, sessionFile] of Object.entries(agentSessions)) {
876
+ try {
877
+ const info = agents[name];
878
+ const usage = parseSessionUsage(sessionFile);
879
+ const pricing = TOKEN_PRICING[usage.model] || TOKEN_PRICING['claude-opus-4-6'];
880
+ 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;
881
+
882
+ result.agents[name] = {
883
+ model: usage.model,
884
+ input_tokens: usage.input_tokens,
885
+ output_tokens: usage.output_tokens,
886
+ cache_creation_tokens: usage.cache_creation_tokens,
887
+ cache_read_tokens: usage.cache_read_tokens,
888
+ total_tokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_tokens + usage.cache_read_tokens,
889
+ estimated_cost_usd: Math.round(cost * 100) / 100,
890
+ messages: usage.messages,
891
+ pid: info.pid,
892
+ };
893
+ result.total_cost_usd += cost;
894
+ result.total_tokens += result.agents[name].total_tokens;
895
+ } catch { /* skip */ }
896
+ }
897
+ result.total_cost_usd = Math.round(result.total_cost_usd * 100) / 100;
898
+ return result;
899
+ }
900
+
486
901
  function apiNotifications() {
487
902
  return notificationHistory;
488
903
  }
@@ -619,7 +1034,7 @@ function apiExportReplay(query) {
619
1034
  const history = readJsonl(filePath('history.jsonl', projectPath));
620
1035
  const profiles = readJson(filePath('profiles.json', projectPath));
621
1036
 
622
- const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
1037
+ const colors = ['#f59e0b','#f97316','#3fb950','#d29922','#f778ba','#7ee787','#e3b341','#14b8a6'];
623
1038
  const agentColors = {};
624
1039
  let colorIdx = 0;
625
1040
  for (const m of history) {
@@ -627,7 +1042,7 @@ function apiExportReplay(query) {
627
1042
  }
628
1043
 
629
1044
  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'
1045
+ from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#f59e0b'
631
1046
  })));
632
1047
 
633
1048
  return `<!DOCTYPE html>
@@ -823,6 +1238,9 @@ function apiLoadConversation(query) {
823
1238
  return { success: true };
824
1239
  }
825
1240
 
1241
+ // Sender names API callers must not use for /api/inject (prevents forged system/group traffic)
1242
+ const INJECT_FROM_BLOCKLIST = new Set(['__system__', '__all__', '__open__', '__close__', '__group__']);
1243
+
826
1244
  // Inject a message from the dashboard (system message or nudge to an agent)
827
1245
  function apiInjectMessage(body, query) {
828
1246
  const projectPath = query.get('project') || null;
@@ -842,28 +1260,51 @@ function apiInjectMessage(body, query) {
842
1260
  return { error: 'Invalid agent name' };
843
1261
  }
844
1262
 
1263
+ let fromName = '__user__';
1264
+ if (body.from !== undefined && body.from !== null && String(body.from).trim() !== '') {
1265
+ if (typeof body.from !== 'string' || !/^[a-zA-Z0-9_-]{1,20}$/.test(body.from.trim())) {
1266
+ return { error: 'Invalid "from" — must be 1–20 alphanumeric, underscore, or hyphen' };
1267
+ }
1268
+ fromName = body.from.trim();
1269
+ if (INJECT_FROM_BLOCKLIST.has(fromName)) {
1270
+ return { error: 'Invalid "from" — reserved name' };
1271
+ }
1272
+ }
1273
+
845
1274
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
846
- const fromName = 'Dashboard';
847
1275
  const now = new Date().toISOString();
848
1276
 
849
- // Broadcast to all agents
1277
+ // Touch sender's heartbeat so inject activity keeps the agent alive in the dashboard
1278
+ if (fromName !== '__user__') {
1279
+ try {
1280
+ const hbFile = path.join(dataDir, `heartbeat-${fromName}.json`);
1281
+ const agentsFile = path.join(dataDir, 'agents.json');
1282
+ const payload = { last_activity: now, pid: process.pid };
1283
+ const tmp = hbFile + '.tmp';
1284
+ fs.writeFileSync(tmp, JSON.stringify(payload));
1285
+ fs.renameSync(tmp, hbFile);
1286
+ if (fs.existsSync(agentsFile)) {
1287
+ const agents = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
1288
+ if (agents[fromName]) {
1289
+ agents[fromName].last_activity = now;
1290
+ fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2));
1291
+ }
1292
+ }
1293
+ } catch {}
1294
+ }
1295
+
1296
+ // Broadcast to all agents — single __group__ message instead of per-agent
850
1297
  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 };
1298
+ const msg = {
1299
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
1300
+ from: fromName,
1301
+ to: '__group__',
1302
+ content: body.content,
1303
+ timestamp: now,
1304
+ };
1305
+ fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
1306
+ fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
1307
+ return { success: true, messageId: msg.id, broadcast: true };
867
1308
  }
868
1309
 
869
1310
  const msg = {
@@ -872,7 +1313,6 @@ function apiInjectMessage(body, query) {
872
1313
  to: body.to,
873
1314
  content: body.content,
874
1315
  timestamp: now,
875
- system: true,
876
1316
  };
877
1317
 
878
1318
  fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
@@ -883,12 +1323,56 @@ function apiInjectMessage(body, query) {
883
1323
 
884
1324
  // Multi-project management
885
1325
  function apiProjects() {
886
- return getProjects();
1326
+ const raw = getProjects();
1327
+ const normalized = raw.map(p => {
1328
+ const np = normalizeMonitoredProjectRoot(p.path);
1329
+ let name = p.name;
1330
+ if (path.resolve(np) !== path.resolve(p.path)) {
1331
+ name = path.basename(np) || p.name;
1332
+ }
1333
+ if (!name || name === '.neohive') {
1334
+ name = path.basename(np) || 'project';
1335
+ }
1336
+ return { ...p, path: np, name };
1337
+ });
1338
+
1339
+ const seen = new Set();
1340
+ const deduped = [];
1341
+ for (const p of normalized) {
1342
+ const key = path.resolve(p.path);
1343
+ if (seen.has(key)) continue;
1344
+ seen.add(key);
1345
+ deduped.push(p);
1346
+ }
1347
+
1348
+ // Drop projects whose hive is the same as “Default (local)” — avoids duplicate rows and agents flickering between two identical paths.
1349
+ const defaultHive = path.resolve(resolveDataDir(null));
1350
+ const nonRedundant = deduped.filter(p => path.resolve(resolveDataDir(p.path)) !== defaultHive);
1351
+
1352
+ const pack = (arr) =>
1353
+ JSON.stringify(
1354
+ [...arr].sort((a, b) => path.resolve(a.path).localeCompare(path.resolve(b.path)))
1355
+ .map(p => ({ p: path.resolve(p.path), n: p.name, a: p.added_at || '' }))
1356
+ );
1357
+ // Compare to on-disk `raw`, not `normalized`: normalized always includes dupes / default-hive
1358
+ // rows that nonRedundant drops, so pack(normalized) !== pack(nonRedundant) would rewrite every
1359
+ // read even when projects.json already matches nonRedundant.
1360
+ if (pack(nonRedundant) !== pack(raw)) {
1361
+ saveProjects(nonRedundant);
1362
+ }
1363
+ return nonRedundant;
887
1364
  }
888
1365
 
889
1366
  function apiAddProject(body) {
890
1367
  if (!body.path) return { error: 'Missing "path" field' };
891
- const absPath = path.resolve(body.path);
1368
+ const rawResolved = path.resolve(String(body.path).trim());
1369
+ if (path.basename(rawResolved) === '.neohive') {
1370
+ return {
1371
+ error:
1372
+ 'Add the repository folder (same as your Cursor workspace root), not .neohive. Data is stored in <repo>/.neohive automatically.',
1373
+ };
1374
+ }
1375
+ const absPath = normalizeMonitoredProjectRoot(rawResolved);
892
1376
 
893
1377
  // Reject root directories and system paths
894
1378
  const normalized = absPath.replace(/\\/g, '/');
@@ -898,11 +1382,21 @@ function apiAddProject(body) {
898
1382
 
899
1383
  if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
900
1384
 
901
- // Any existing directory can be added as a project — user explicitly chose it
1385
+ const targetHive = path.resolve(resolveDataDir(absPath));
1386
+ const defaultHive = path.resolve(resolveDataDir(null));
1387
+ if (targetHive === defaultHive) {
1388
+ return {
1389
+ error:
1390
+ 'That folder uses the same Neohive data directory as “Default (local)”. No separate project is needed.',
1391
+ same_as_default: true,
1392
+ };
1393
+ }
902
1394
 
903
1395
  const projects = getProjects();
904
1396
  const name = body.name || path.basename(absPath);
905
- if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
1397
+ if (projects.find(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) === absPath)) {
1398
+ return { error: 'Project already added' };
1399
+ }
906
1400
 
907
1401
  // Create .neohive directory if it doesn't exist
908
1402
  const abDir = path.join(absPath, '.neohive');
@@ -911,20 +1405,29 @@ function apiAddProject(body) {
911
1405
  // Set up MCP config so agents can use it
912
1406
  const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
913
1407
  ensureMCPConfig('claude', serverPath, absPath);
1408
+ ensureMCPConfig('cursor', serverPath, absPath);
914
1409
 
915
1410
  projects.push({ name, path: absPath, added_at: new Date().toISOString() });
916
- saveProjects(projects);
1411
+ try {
1412
+ saveProjects(projects);
1413
+ } catch (e) {
1414
+ return { error: 'Failed to save project: ' + e.message };
1415
+ }
917
1416
  return { success: true, project: { name, path: absPath } };
918
1417
  }
919
1418
 
920
1419
  function apiRemoveProject(body) {
921
1420
  if (!body.path) return { error: 'Missing "path" field' };
922
- const absPath = path.resolve(body.path);
1421
+ const absPath = normalizeMonitoredProjectRoot(path.resolve(body.path));
923
1422
  let projects = getProjects();
924
1423
  const before = projects.length;
925
- projects = projects.filter(p => p.path !== absPath);
1424
+ projects = projects.filter(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) !== absPath);
926
1425
  if (projects.length === before) return { error: 'Project not found' };
927
- saveProjects(projects);
1426
+ try {
1427
+ saveProjects(projects);
1428
+ } catch (e) {
1429
+ return { error: 'Failed to save project changes: ' + e.message };
1430
+ }
928
1431
  return { success: true };
929
1432
  }
930
1433
 
@@ -947,15 +1450,15 @@ function apiExportHtml(query) {
947
1450
  return `<!DOCTYPE html>
948
1451
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
949
1452
  <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>">
1453
+ <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
1454
  <style>
952
1455
  *{margin:0;padding:0;box-sizing:border-box}
953
1456
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e6edf3;min-height:100vh}
954
1457
  .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}
1458
+ .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
1459
  .export-meta{margin-top:12px;display:flex;justify-content:center;gap:20px;flex-wrap:wrap}
957
1460
  .meta-item{font-size:12px;color:#8888a0}
958
- .meta-val{color:#58a6ff;font-weight:600}
1461
+ .meta-val{color:#f59e0b;font-weight:600}
959
1462
  .agent-chips{display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap}
960
1463
  .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
1464
  .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 +1492,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
989
1492
  .date-sep::before,.date-sep::after{content:'';flex:1;height:1px;background:#1e1e2e}
990
1493
  .footer{border-top:1px solid #1e1e2e;padding:24px;text-align:center;font-size:11px;color:#555568}
991
1494
  .footer a{color:#8888a0;text-decoration:none}
992
- .footer a:hover{color:#58a6ff}
1495
+ .footer a:hover{color:#f59e0b}
993
1496
  </style></head><body>
994
1497
  <div class="export-header">
995
1498
  <div class="logo">Neohive</div>
@@ -1004,7 +1507,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
1004
1507
  <div class="messages" id="messages"></div>
1005
1508
  <div class="footer">Generated by <a href="https://github.com/fakiho/neohive" target="_blank">Neohive</a> &middot; BSL 1.1</div>
1006
1509
  <script>
1007
- var COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#f778ba','#79c0ff','#7ee787','#e3b341','#ffa198'];
1510
+ var COLORS=['#f59e0b','#f97316','#3fb950','#d29922','#f85149','#f778ba','#7ee787','#e3b341','#ffa198','#14b8a6'];
1008
1511
  var colorMap={},ci=0;
1009
1512
  var data=${JSON.stringify(history).replace(/<\//g, '<\\/')};
1010
1513
  function esc(t){var d=document.createElement('div');d.textContent=t;return d.innerHTML}
@@ -1124,6 +1627,26 @@ function apiRules(query) {
1124
1627
  try { return JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch { return []; }
1125
1628
  }
1126
1629
 
1630
+ function parseScope(scope) {
1631
+ const result = { role: undefined, provider: undefined, agent: undefined };
1632
+ if (!scope) return result;
1633
+ if (typeof scope === 'object') {
1634
+ if (scope.role) result.role = String(scope.role).toLowerCase();
1635
+ if (scope.provider) result.provider = String(scope.provider).toLowerCase();
1636
+ if (scope.agent) result.agent = String(scope.agent);
1637
+ } else if (typeof scope === 'string' && scope !== 'global') {
1638
+ const parts = scope.split(':');
1639
+ if (parts.length === 2) {
1640
+ const type = parts[0].toLowerCase();
1641
+ const val = parts[1];
1642
+ if (type === 'role') result.role = val.toLowerCase();
1643
+ else if (type === 'platform' || type === 'provider') result.provider = val.toLowerCase();
1644
+ else if (type === 'agent') result.agent = val;
1645
+ }
1646
+ }
1647
+ return result;
1648
+ }
1649
+
1127
1650
  function apiAddRule(body, query) {
1128
1651
  const projectPath = query.get('project') || null;
1129
1652
  const rulesFile = filePath('rules.json', projectPath);
@@ -1135,11 +1658,15 @@ function apiAddRule(body, query) {
1135
1658
  try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
1136
1659
  }
1137
1660
 
1661
+ const parsedScope = parseScope(body.scope);
1138
1662
  const rule = {
1139
1663
  id: 'rule_' + crypto.randomBytes(6).toString('hex'),
1140
1664
  text: body.text.trim(),
1141
1665
  category: body.category || 'general',
1142
1666
  priority: body.priority || 'normal',
1667
+ scope_role: parsedScope.role,
1668
+ scope_provider: parsedScope.provider,
1669
+ scope_agent: parsedScope.agent,
1143
1670
  created_by: body.created_by || 'Dashboard',
1144
1671
  created_at: new Date().toISOString(),
1145
1672
  active: true
@@ -1165,6 +1692,12 @@ function apiUpdateRule(body, query) {
1165
1692
  if (body.text !== undefined) rule.text = body.text.trim();
1166
1693
  if (body.category !== undefined) rule.category = body.category;
1167
1694
  if (body.priority !== undefined) rule.priority = body.priority;
1695
+ if (body.scope !== undefined) {
1696
+ const parsedScope = parseScope(body.scope);
1697
+ rule.scope_role = parsedScope.role;
1698
+ rule.scope_provider = parsedScope.provider;
1699
+ rule.scope_agent = parsedScope.agent;
1700
+ }
1168
1701
  if (body.active !== undefined) rule.active = body.active;
1169
1702
  rule.updated_at = new Date().toISOString();
1170
1703
 
@@ -1190,11 +1723,68 @@ function apiDeleteRule(body, query) {
1190
1723
  return { success: true };
1191
1724
  }
1192
1725
 
1726
+ // Audit Log API
1727
+ function apiAuditLog(query) {
1728
+ const projectPath = query.get('project') || null;
1729
+
1730
+ // For backward compatibility, if no enhanced filters are used, use old method
1731
+ const hasFilters = query.get('agent') || query.get('tool') || query.get('category') ||
1732
+ query.get('since') || query.get('until') || query.get('limit');
1733
+
1734
+ if (!hasFilters) {
1735
+ // Legacy behavior: Read entries, take last 100, newest first
1736
+ return readJsonl(filePath('audit_log.jsonl', projectPath)).slice(-100).reverse();
1737
+ }
1738
+
1739
+ // Enhanced audit log with filters using audit module
1740
+ const filters = {
1741
+ agent: query.get('agent') || undefined,
1742
+ tool: query.get('tool') || undefined,
1743
+ category: query.get('category') || undefined,
1744
+ since: query.get('since') || undefined,
1745
+ until: query.get('until') || undefined,
1746
+ limit: query.get('limit') || undefined
1747
+ };
1748
+
1749
+ // Initialize audit module with project path if needed
1750
+ if (projectPath) {
1751
+ const auditDataDir = path.join(projectPath, '.neohive');
1752
+ if (fs.existsSync(auditDataDir)) {
1753
+ _audit.init(auditDataDir);
1754
+ }
1755
+ }
1756
+
1757
+ return _audit.readAuditLog(filters);
1758
+ }
1759
+
1760
+ // Audit Stats API
1761
+ function apiAuditStats(query) {
1762
+ const projectPath = query.get('project') || null;
1763
+
1764
+ const filters = {
1765
+ agent: query.get('agent') || undefined,
1766
+ tool: query.get('tool') || undefined,
1767
+ category: query.get('category') || undefined,
1768
+ since: query.get('since') || undefined,
1769
+ until: query.get('until') || undefined
1770
+ };
1771
+
1772
+ // Initialize audit module with project path if needed
1773
+ if (projectPath) {
1774
+ const auditDataDir = path.join(projectPath, '.neohive');
1775
+ if (fs.existsSync(auditDataDir)) {
1776
+ _audit.init(auditDataDir);
1777
+ }
1778
+ }
1779
+
1780
+ return _audit.getAuditStats(filters);
1781
+ }
1782
+
1193
1783
  // Auto-discover .neohive directories nearby
1194
1784
  function apiDiscover() {
1195
1785
  const found = [];
1196
1786
  const checked = new Set();
1197
- const existing = new Set(getProjects().map(p => p.path));
1787
+ const existing = new Set(getProjects().map(p => normalizeMonitoredProjectRoot(path.resolve(p.path))));
1198
1788
 
1199
1789
  function scanDir(dir, depth, maxDepth) {
1200
1790
  maxDepth = maxDepth || 3;
@@ -1238,6 +1828,11 @@ function apiDiscover() {
1238
1828
 
1239
1829
  // --- Agent Launcher ---
1240
1830
 
1831
+ /** Same as cli.js: absolute Node path so MCP spawns work when PATH omits Volta/nvm. */
1832
+ function mcpNodeCommand() {
1833
+ return process.execPath;
1834
+ }
1835
+
1241
1836
  function ensureMCPConfig(cli, serverPath, projectDir) {
1242
1837
  const abDir = path.join(projectDir, '.neohive').replace(/\\/g, '/');
1243
1838
  if (cli === 'claude') {
@@ -1247,7 +1842,7 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1247
1842
  try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
1248
1843
  }
1249
1844
  if (!mcpConfig.mcpServers['neohive']) {
1250
- mcpConfig.mcpServers['neohive'] = { command: 'node', args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
1845
+ mcpConfig.mcpServers['neohive'] = { command: mcpNodeCommand(), args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
1251
1846
  fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
1252
1847
  }
1253
1848
  } else if (cli === 'gemini') {
@@ -1259,7 +1854,7 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1259
1854
  try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); if (!settings.mcpServers) settings.mcpServers = {}; } catch {}
1260
1855
  }
1261
1856
  if (!settings.mcpServers['neohive']) {
1262
- settings.mcpServers['neohive'] = { command: 'node', args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
1857
+ settings.mcpServers['neohive'] = { command: mcpNodeCommand(), args: [serverPath], env: { NEOHIVE_DATA_DIR: abDir } };
1263
1858
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1264
1859
  }
1265
1860
  } else if (cli === 'codex') {
@@ -1268,17 +1863,40 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1268
1863
  if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
1269
1864
  let config = '';
1270
1865
  if (fs.existsSync(configPath)) config = fs.readFileSync(configPath, 'utf8');
1271
- if (!config.includes('[mcp_servers.neohive]')) {
1272
- config += `\n[mcp_servers.neohive]\ncommand = "node"\nargs = [${JSON.stringify(serverPath)}]\n\n[mcp_servers.neohive.env]\nNEOHIVE_DATA_DIR = ${JSON.stringify(abDir)}\n`;
1273
- fs.writeFileSync(configPath, config);
1866
+ const envSection =
1867
+ `[mcp_servers.neohive.env]\nNEOHIVE_DATA_DIR = ${JSON.stringify(abDir)}\n`;
1868
+ const hadNeohive = config.includes('[mcp_servers.neohive]');
1869
+ config = upsertNeohiveMcpInToml(config, {
1870
+ command: mcpNodeCommand(),
1871
+ serverPath,
1872
+ timeout: 300,
1873
+ envSection: hadNeohive ? undefined : envSection,
1874
+ });
1875
+ fs.writeFileSync(configPath, config);
1876
+ } else if (cli === 'cursor') {
1877
+ const cursorDir = path.join(projectDir, '.cursor');
1878
+ const mcpConfigPath = path.join(cursorDir, 'mcp.json');
1879
+ if (!fs.existsSync(cursorDir)) fs.mkdirSync(cursorDir, { recursive: true });
1880
+ let mcpConfig = { mcpServers: {} };
1881
+ if (fs.existsSync(mcpConfigPath)) {
1882
+ try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
1883
+ }
1884
+ if (!mcpConfig.mcpServers['neohive']) {
1885
+ mcpConfig.mcpServers['neohive'] = {
1886
+ command: mcpNodeCommand(),
1887
+ args: [serverPath],
1888
+ env: { NEOHIVE_DATA_DIR: abDir },
1889
+ timeout: 300,
1890
+ };
1891
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
1274
1892
  }
1275
1893
  }
1276
1894
  }
1277
1895
 
1278
1896
  function apiLaunchAgent(body) {
1279
1897
  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' };
1898
+ if (!cli || !['claude', 'gemini', 'codex', 'cursor'].includes(cli)) {
1899
+ return { error: 'Invalid cli type. Must be: claude, gemini, codex, or cursor' };
1282
1900
  }
1283
1901
  if (project_dir && !validateProjectPath(project_dir)) {
1284
1902
  return { error: 'Project directory not registered. Add it via the dashboard first.' };
@@ -1288,13 +1906,25 @@ function apiLaunchAgent(body) {
1288
1906
  return { error: 'Project directory does not exist: ' + projectDir };
1289
1907
  }
1290
1908
 
1909
+ const safeName = (agent_name || '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
1910
+ 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.`);
1911
+
1291
1912
  const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
1292
1913
  ensureMCPConfig(cli, serverPath, projectDir);
1293
1914
 
1915
+ if (cli === 'cursor') {
1916
+ return {
1917
+ success: true,
1918
+ launched: false,
1919
+ cli: 'cursor',
1920
+ project_dir: projectDir,
1921
+ prompt: launchPrompt,
1922
+ 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.',
1923
+ };
1924
+ }
1925
+
1294
1926
  const cliCommands = { claude: 'claude', gemini: 'gemini', codex: 'codex' };
1295
1927
  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
1928
 
1299
1929
  // Try to launch terminal — user pastes prompt from clipboard after CLI loads
1300
1930
  if (process.platform === 'win32') {
@@ -1596,6 +2226,57 @@ setInterval(() => {
1596
2226
  }
1597
2227
  }, 300000).unref(); // Clean every 5 minutes, .unref() prevents zombie process
1598
2228
 
2229
+ // ─────────────────────────────────────────────────────────────────────────────
2230
+ // ROUTE DISPATCH TABLE
2231
+ // Simple GET/POST routes are registered here as { method, handler } entries.
2232
+ // Complex routes (body parsing, SSE, multi-step logic) remain inline below.
2233
+ // Key format: 'METHOD /path' e.g. 'GET /api/agents'
2234
+ // ─────────────────────────────────────────────────────────────────────────────
2235
+ function routeKey(method, pathname) { return method + ' ' + pathname; }
2236
+
2237
+ /** @type {Map<string, (req: any, res: any, url: URL) => void | Promise<void>>} */
2238
+ const ROUTE_TABLE = new Map([
2239
+ // Simple GET routes — each maps to a standalone API function
2240
+ [routeKey('GET', '/api/history'), (req, res, url) => jsonOk(res, apiHistory(url.searchParams))],
2241
+ [routeKey('GET', '/api/agents'), (req, res, url) => jsonOk(res, apiAgents(url.searchParams))],
2242
+ [routeKey('GET', '/api/channels'), (req, res, url) => jsonOk(res, apiChannels(url.searchParams))],
2243
+ [routeKey('GET', '/api/decisions'), (req, res, url) => jsonOk(res, readJson(filePath('decisions.json', url.searchParams.get('project') || null)) || [])],
2244
+ [routeKey('GET', '/api/status'), (req, res, url) => jsonOk(res, apiStatus(url.searchParams))],
2245
+ [routeKey('GET', '/api/stats'), (req, res, url) => jsonOk(res, apiStats(url.searchParams))],
2246
+ [routeKey('GET', '/api/token-usage'), (req, res, url) => jsonOk(res, apiTokenUsage(url.searchParams))],
2247
+ [routeKey('GET', '/api/coordinator-mode'),(req, res, url) => {
2248
+ const config = readJson(filePath('config.json', url.searchParams.get('project') || null));
2249
+ jsonOk(res, { mode: config.coordinator_mode || 'autonomous', config });
2250
+ }],
2251
+ [routeKey('GET', '/api/projects'), (req, res, url) => jsonOk(res, apiProjects())],
2252
+ [routeKey('GET', '/api/timeline'), (req, res, url) => jsonOk(res, apiTimeline(url.searchParams))],
2253
+ [routeKey('GET', '/api/tasks'), (req, res, url) => jsonOk(res, apiTasks(url.searchParams))],
2254
+ [routeKey('GET', '/api/rules'), (req, res, url) => jsonOk(res, apiRules(url.searchParams))],
2255
+ [routeKey('GET', '/api/audit-log'), (req, res, url) => jsonOk(res, apiAuditLog(url.searchParams))],
2256
+ [routeKey('GET', '/api/audit-stats'), (req, res, url) => jsonOk(res, apiAuditStats(url.searchParams))],
2257
+ [routeKey('GET', '/api/notifications'), (req, res, url) => jsonOk(res, apiNotifications())],
2258
+ [routeKey('GET', '/api/scores'), (req, res, url) => jsonOk(res, apiScores(url.searchParams))],
2259
+ [routeKey('GET', '/api/search-all'), (req, res, url) => jsonOk(res, apiSearchAll(url.searchParams))],
2260
+ [routeKey('GET', '/api/hooks'), (req, res, url) => {
2261
+ try { const hooksLib = require('./lib/hooks'); jsonOk(res, hooksLib.listHooks(null)); }
2262
+ catch (e) { jsonOk(res, { count: 0, hooks: [], error: e.message }); }
2263
+ }],
2264
+ [routeKey('GET', '/api/export-replay'), (req, res, url) => jsonOk(res, apiExportReplay(url.searchParams))],
2265
+ // Routes below have complex inline logic and remain in the else-if chain for safety.
2266
+ // TODO: extract to standalone functions in a future refactor:
2267
+ // /api/search, /api/export-json, /api/conversations, /api/profiles, /api/workspaces,
2268
+ // /api/workflows, /api/plan/*, /api/monitor/health, /api/reputation, /api/branches,
2269
+ // /api/conversation-templates, /api/permissions, /api/read-receipts, /api/server-info,
2270
+ // /api/templates
2271
+ ]);
2272
+
2273
+ /** Send a 200 JSON response — shared helper for route table handlers */
2274
+ function jsonOk(res, data) {
2275
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2276
+ res.end(JSON.stringify(data));
2277
+ }
2278
+
2279
+
1599
2280
  const server = http.createServer(async (req, res) => {
1600
2281
  const url = new URL(req.url, 'http://localhost:' + PORT);
1601
2282
 
@@ -1698,11 +2379,66 @@ const server = http.createServer(async (req, res) => {
1698
2379
  return;
1699
2380
  }
1700
2381
 
1701
- // Serve logo image
2382
+ // Health check — lightweight, no auth required
2383
+ if (url.pathname === '/health' && req.method === 'GET') {
2384
+ const pkg = readJson(path.join(__dirname, 'package.json')) || {};
2385
+ const defaultDataDir = resolveDataDir(null);
2386
+ const agents = readJson(filePath('agents.json', null));
2387
+ const agentEntries = Object.entries(agents);
2388
+ const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid, a.last_activity)).length;
2389
+ let messageCount = 0;
2390
+ const histFile = filePath('history.jsonl', null);
2391
+ if (fs.existsSync(histFile)) {
2392
+ try { messageCount = Math.round(fs.statSync(histFile).size / 300); } catch {}
2393
+ }
2394
+ let activeWorkflows = 0;
2395
+ const wfFile = filePath('workflows.json', null);
2396
+ if (fs.existsSync(wfFile)) {
2397
+ try { activeWorkflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')).filter(w => w.status === 'active').length; } catch {}
2398
+ }
2399
+ const uptimeMs = Date.now() - SERVER_START_TIME;
2400
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2401
+ res.end(JSON.stringify({
2402
+ status: 'ok',
2403
+ version: pkg.version || 'unknown',
2404
+ uptime_seconds: Math.floor(uptimeMs / 1000),
2405
+ agents: { alive: aliveCount, total: agentEntries.length },
2406
+ messages: messageCount,
2407
+ active_workflows: activeWorkflows,
2408
+ timestamp: new Date().toISOString(),
2409
+ }));
2410
+ return;
2411
+ }
2412
+
2413
+ // Serve logo images
2414
+ if (url.pathname === '/favicon.png') {
2415
+ if (fs.existsSync(FAVICON_FILE)) {
2416
+ const favicon = fs.readFileSync(FAVICON_FILE);
2417
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
2418
+ res.end(favicon);
2419
+ } else {
2420
+ res.writeHead(404);
2421
+ res.end('Favicon not found');
2422
+ }
2423
+ return;
2424
+ }
2425
+
2426
+ if (url.pathname === '/logo.svg') {
2427
+ if (fs.existsSync(LOGO_SVG_FILE)) {
2428
+ const logo = fs.readFileSync(LOGO_SVG_FILE);
2429
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
2430
+ res.end(logo);
2431
+ } else {
2432
+ res.writeHead(404);
2433
+ res.end('Logo not found');
2434
+ }
2435
+ return;
2436
+ }
2437
+
1702
2438
  if (url.pathname === '/logo.png') {
1703
2439
  if (fs.existsSync(LOGO_FILE)) {
1704
2440
  const logo = fs.readFileSync(LOGO_FILE);
1705
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
2441
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
1706
2442
  res.end(logo);
1707
2443
  } else {
1708
2444
  res.writeHead(404);
@@ -1711,12 +2447,46 @@ const server = http.createServer(async (req, res) => {
1711
2447
  return;
1712
2448
  }
1713
2449
 
2450
+ if (url.pathname === '/design-system.css' && req.method === 'GET') {
2451
+ if (fs.existsSync(DESIGN_SYSTEM_CSS)) {
2452
+ const css = fs.readFileSync(DESIGN_SYSTEM_CSS, 'utf8');
2453
+ res.writeHead(200, {
2454
+ 'Content-Type': 'text/css; charset=utf-8',
2455
+ 'Cache-Control': 'public, max-age=3600',
2456
+ });
2457
+ res.end(css);
2458
+ } else {
2459
+ res.writeHead(404);
2460
+ res.end('Not found');
2461
+ }
2462
+ return;
2463
+ }
2464
+
2465
+ if (url.pathname === '/design-system.html' && req.method === 'GET') {
2466
+ if (fs.existsSync(DESIGN_SYSTEM_HTML)) {
2467
+ const html = fs.readFileSync(DESIGN_SYSTEM_HTML, 'utf8');
2468
+ res.writeHead(200, {
2469
+ 'Content-Type': 'text/html; charset=utf-8',
2470
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
2471
+ 'X-Frame-Options': 'DENY',
2472
+ 'X-Content-Type-Options': 'nosniff',
2473
+ 'Referrer-Policy': 'no-referrer',
2474
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
2475
+ });
2476
+ res.end(html);
2477
+ } else {
2478
+ res.writeHead(404);
2479
+ res.end('Not found');
2480
+ }
2481
+ return;
2482
+ }
2483
+
1714
2484
  // Serve dashboard HTML (always re-read for hot reload)
1715
2485
  if (url.pathname === '/' || url.pathname === '/index.html') {
1716
2486
  const html = fs.readFileSync(HTML_FILE, 'utf8');
1717
2487
  res.writeHead(200, {
1718
2488
  'Content-Type': 'text/html; charset=utf-8',
1719
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
2489
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
1720
2490
  'X-Frame-Options': 'DENY',
1721
2491
  'X-Content-Type-Options': 'nosniff',
1722
2492
  'Referrer-Policy': 'no-referrer',
@@ -1726,25 +2496,11 @@ const server = http.createServer(async (req, res) => {
1726
2496
  });
1727
2497
  res.end(html);
1728
2498
  }
1729
- // Existing APIs (now with ?project= param support)
1730
- else if (url.pathname === '/api/history' && req.method === 'GET') {
1731
- res.writeHead(200, { 'Content-Type': 'application/json' });
1732
- res.end(JSON.stringify(apiHistory(url.searchParams)));
1733
- }
1734
- else if (url.pathname === '/api/agents' && req.method === 'GET') {
1735
- res.writeHead(200, { 'Content-Type': 'application/json' });
1736
- res.end(JSON.stringify(apiAgents(url.searchParams)));
1737
- }
1738
- else if (url.pathname === '/api/channels' && req.method === 'GET') {
1739
- res.writeHead(200, { 'Content-Type': 'application/json' });
1740
- res.end(JSON.stringify(apiChannels(url.searchParams)));
1741
- }
1742
- else if (url.pathname === '/api/decisions' && req.method === 'GET') {
1743
- const projectPath = url.searchParams.get('project') || null;
1744
- const decisions = readJson(filePath('decisions.json', projectPath));
1745
- res.writeHead(200, { 'Content-Type': 'application/json' });
1746
- res.end(JSON.stringify(decisions || []));
2499
+ // ── Route table dispatch (simple GET routes) ──────────────────────────────
2500
+ else if (ROUTE_TABLE.has(routeKey(req.method, url.pathname))) {
2501
+ await ROUTE_TABLE.get(routeKey(req.method, url.pathname))(req, res, url);
1747
2502
  }
2503
+ // ── Complex routes (body parsing, SSE, multi-step logic) ──────────────────
1748
2504
  else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
1749
2505
  const body = await parseBody(req);
1750
2506
  if (!body.name) {
@@ -1916,6 +2672,60 @@ const server = http.createServer(async (req, res) => {
1916
2672
  res.writeHead(200, { 'Content-Type': 'application/json' });
1917
2673
  res.end(JSON.stringify(apiStats(url.searchParams)));
1918
2674
  }
2675
+ else if (url.pathname === '/api/token-usage' && req.method === 'GET') {
2676
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2677
+ res.end(JSON.stringify(apiTokenUsage(url.searchParams)));
2678
+ }
2679
+ else if (url.pathname === '/api/coordinator-mode' && req.method === 'GET') {
2680
+ const projectPath = url.searchParams.get('project') || null;
2681
+ const config = readJson(filePath('config.json', projectPath));
2682
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2683
+ res.end(JSON.stringify({ mode: config.coordinator_mode || 'responsive' }));
2684
+ }
2685
+ else if (url.pathname === '/api/coordinator-mode' && req.method === 'POST') {
2686
+ try {
2687
+ const body = await parseBody(req).catch(() => ({}));
2688
+ const newMode = body.mode;
2689
+ if (!newMode || !['responsive', 'autonomous'].includes(newMode)) {
2690
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2691
+ res.end(JSON.stringify({ error: 'mode must be "responsive" or "autonomous"' }));
2692
+ return;
2693
+ }
2694
+ const projectPath = url.searchParams.get('project') || null;
2695
+ const dataDir = resolveDataDir(projectPath);
2696
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
2697
+ const configFile = filePath('config.json', projectPath);
2698
+ await withFileLock(configFile, () => {
2699
+ const config = readJson(configFile);
2700
+ config.coordinator_mode = newMode;
2701
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
2702
+ });
2703
+ // Broadcast mode change to all agents + direct message to lead agents
2704
+ try {
2705
+ const messagesFile = filePath('messages.jsonl', projectPath);
2706
+ const historyFile = filePath('history.jsonl', projectPath);
2707
+ const modeText = newMode === 'responsive' ? 'Coordinator stays with human, uses consume_messages().' : 'Coordinator runs autonomously in listen() loop.';
2708
+ 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 };
2709
+ fs.appendFileSync(messagesFile, JSON.stringify(sysMsg) + '\n');
2710
+ fs.appendFileSync(historyFile, JSON.stringify(sysMsg) + '\n');
2711
+ // Also send direct message to lead/coordinator agents so listen() in direct mode picks it up
2712
+ const profiles = readJson(filePath('profiles.json', projectPath));
2713
+ for (const [agentName, prof] of Object.entries(profiles)) {
2714
+ const role = (prof.role || '').toLowerCase();
2715
+ if (role === 'lead' || role === 'manager' || role === 'coordinator') {
2716
+ 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 };
2717
+ fs.appendFileSync(messagesFile, JSON.stringify(directMsg) + '\n');
2718
+ fs.appendFileSync(historyFile, JSON.stringify(directMsg) + '\n');
2719
+ }
2720
+ }
2721
+ } catch (e) { /* broadcast is best-effort */ }
2722
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2723
+ res.end(JSON.stringify({ success: true, mode: newMode }));
2724
+ } catch (e) {
2725
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2726
+ res.end(JSON.stringify({ error: 'Failed to set coordinator mode: ' + e.message }));
2727
+ }
2728
+ }
1919
2729
  else if (url.pathname === '/api/reset' && req.method === 'POST') {
1920
2730
  const body = await parseBody(req).catch(() => ({}));
1921
2731
  if (!body.confirm) {
@@ -1987,6 +2797,77 @@ const server = http.createServer(async (req, res) => {
1987
2797
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1988
2798
  res.end(JSON.stringify(result));
1989
2799
  }
2800
+ else if (url.pathname === '/api/tasks' && req.method === 'PUT') {
2801
+ const body = await parseBody(req);
2802
+ if (!body.task_id) {
2803
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2804
+ res.end(JSON.stringify({ error: 'Missing task_id' }));
2805
+ return;
2806
+ }
2807
+ const projectPath = url.searchParams.get('project') || null;
2808
+ const tasksDir = resolveTasksWorkflowsDataDir(projectPath);
2809
+ const tasksFile = path.join(tasksDir, 'tasks.json');
2810
+ const msgDir = resolveDataDir(projectPath);
2811
+ let tasks = [];
2812
+ if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
2813
+ const task = tasks.find(t => t.id === body.task_id);
2814
+ if (!task) {
2815
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2816
+ res.end(JSON.stringify({ error: 'Task not found' }));
2817
+ return;
2818
+ }
2819
+ const oldAssignee = task.assignee;
2820
+ const msgFile = path.join(msgDir, 'messages.jsonl');
2821
+ const histFile = path.join(msgDir, 'history.jsonl');
2822
+ // Apply edits
2823
+ if (body.title !== undefined) task.title = body.title;
2824
+ if (body.description !== undefined) task.description = body.description;
2825
+ if (body.assignee !== undefined) task.assignee = body.assignee;
2826
+ task.updated_at = new Date().toISOString();
2827
+ fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
2828
+ // Notify agents on changes
2829
+ const writeMsg = (to, content) => {
2830
+ 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';
2831
+ try { fs.appendFileSync(msgFile, msg); fs.appendFileSync(histFile, msg); } catch {}
2832
+ };
2833
+ if (body.assignee !== undefined && body.assignee !== oldAssignee) {
2834
+ if (body.assignee) writeMsg(body.assignee, '[TASK ASSIGNED] Task "' + task.title + '" assigned to you');
2835
+ if (oldAssignee) writeMsg(oldAssignee, '[TASK REASSIGNED] Task "' + task.title + '" reassigned to ' + (body.assignee || 'unassigned'));
2836
+ } else if (body.description !== undefined && task.assignee) {
2837
+ writeMsg(task.assignee, '[TASK UPDATED] Task "' + task.title + '" description updated');
2838
+ }
2839
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2840
+ res.end(JSON.stringify({ success: true, task_id: task.id }));
2841
+ }
2842
+ else if (url.pathname === '/api/tasks' && req.method === 'DELETE') {
2843
+ const body = await parseBody(req);
2844
+ if (!body.task_id) {
2845
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2846
+ res.end(JSON.stringify({ error: 'Missing task_id' }));
2847
+ return;
2848
+ }
2849
+ const projectPath = url.searchParams.get('project') || null;
2850
+ const tasksDir = resolveTasksWorkflowsDataDir(projectPath);
2851
+ const tasksFile = path.join(tasksDir, 'tasks.json');
2852
+ const msgDir = resolveDataDir(projectPath);
2853
+ let tasks = [];
2854
+ if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
2855
+ const idx = tasks.findIndex(t => t.id === body.task_id);
2856
+ if (idx === -1) {
2857
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2858
+ res.end(JSON.stringify({ error: 'Task not found' }));
2859
+ return;
2860
+ }
2861
+ const removed = tasks.splice(idx, 1)[0];
2862
+ fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
2863
+ // Write system message about deletion
2864
+ const msgFile = path.join(msgDir, 'messages.jsonl');
2865
+ const histFile = path.join(msgDir, 'history.jsonl');
2866
+ 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';
2867
+ try { fs.appendFileSync(msgFile, sysMsg); fs.appendFileSync(histFile, sysMsg); } catch {}
2868
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2869
+ res.end(JSON.stringify({ success: true, removed: removed.title }));
2870
+ }
1990
2871
  else if (url.pathname === '/api/rules' && req.method === 'GET') {
1991
2872
  res.writeHead(200, { 'Content-Type': 'application/json' });
1992
2873
  res.end(JSON.stringify(apiRules(url.searchParams)));
@@ -2002,6 +2883,10 @@ const server = http.createServer(async (req, res) => {
2002
2883
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2003
2884
  res.end(JSON.stringify(result));
2004
2885
  }
2886
+ else if (url.pathname === '/api/audit-log' && req.method === 'GET') {
2887
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2888
+ res.end(JSON.stringify(apiAuditLog(url.searchParams)));
2889
+ }
2005
2890
  else if (url.pathname === '/api/search' && req.method === 'GET') {
2006
2891
  const projectPath = url.searchParams.get('project') || null;
2007
2892
  const query = (url.searchParams.get('q') || '').trim();
@@ -2089,6 +2974,29 @@ const server = http.createServer(async (req, res) => {
2089
2974
  res.writeHead(200, { 'Content-Type': 'application/json' });
2090
2975
  res.end(JSON.stringify(apiDiscover()));
2091
2976
  }
2977
+ // --- GitHub Projects sync ---
2978
+ else if (url.pathname === '/api/github-sync' && req.method === 'GET') {
2979
+ try {
2980
+ const ghSync = require('./lib/github-sync');
2981
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2982
+ res.end(JSON.stringify(ghSync.getSyncStatus()));
2983
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
2984
+ }
2985
+ else if (url.pathname === '/api/github-sync' && req.method === 'POST') {
2986
+ try {
2987
+ const ghSync = require('./lib/github-sync');
2988
+ const body = await parseBody(req);
2989
+ if (body.action === 'discover') {
2990
+ const result = await ghSync.discoverFields();
2991
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2992
+ res.end(JSON.stringify(result));
2993
+ } else {
2994
+ const result = await ghSync.syncAllTasks();
2995
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2996
+ res.end(JSON.stringify(result));
2997
+ }
2998
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
2999
+ }
2092
3000
  // --- v3.0 API endpoints ---
2093
3001
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
2094
3002
  const projectPath = url.searchParams.get('project') || null;
@@ -2114,6 +3022,29 @@ const server = http.createServer(async (req, res) => {
2114
3022
  res.writeHead(200, { 'Content-Type': 'application/json' });
2115
3023
  res.end(JSON.stringify({ success: true }));
2116
3024
  }
3025
+ else if (url.pathname === '/api/agent-cards' && req.method === 'POST') {
3026
+ const body = await parseBody(req);
3027
+ const projectPath = url.searchParams.get('project') || null;
3028
+ const cardsFile = filePath('agent-cards.json', projectPath);
3029
+ const cards = readJson(cardsFile);
3030
+ if (!body.name || !/^[a-zA-Z0-9_-]{1,20}$/.test(body.name)) {
3031
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3032
+ res.end(JSON.stringify({ error: 'Invalid agent name' }));
3033
+ return;
3034
+ }
3035
+ if (body.skills !== undefined && !Array.isArray(body.skills)) {
3036
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3037
+ res.end(JSON.stringify({ error: 'skills must be an array' }));
3038
+ return;
3039
+ }
3040
+ if (!cards[body.name]) cards[body.name] = {};
3041
+ if (body.skills !== undefined) {
3042
+ cards[body.name].skills = body.skills.map(s => String(s).toLowerCase().substring(0, 50));
3043
+ }
3044
+ fs.writeFileSync(cardsFile, JSON.stringify(cards, null, 2));
3045
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3046
+ res.end(JSON.stringify({ success: true }));
3047
+ }
2117
3048
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
2118
3049
  const projectPath = url.searchParams.get('project') || null;
2119
3050
  const agentParam = url.searchParams.get('agent');
@@ -2137,12 +3068,42 @@ const server = http.createServer(async (req, res) => {
2137
3068
  res.writeHead(200, { 'Content-Type': 'application/json' });
2138
3069
  res.end(JSON.stringify(result));
2139
3070
  }
3071
+ else if (url.pathname === '/api/notifications' && req.method === 'GET') {
3072
+ const projectPath = url.searchParams.get('project') || null;
3073
+ const notifFile = filePath('notifications.json', projectPath);
3074
+ const notifs = fs.existsSync(notifFile) ? JSON.parse(fs.readFileSync(notifFile, 'utf8')) : [];
3075
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
3076
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3077
+ res.end(JSON.stringify(notifs.slice(-limit)));
3078
+ }
2140
3079
  else if (url.pathname === '/api/workflows' && req.method === 'GET') {
2141
3080
  const projectPath = url.searchParams.get('project') || null;
2142
3081
  const wfFile = filePath('workflows.json', projectPath);
2143
3082
  res.writeHead(200, { 'Content-Type': 'application/json' });
2144
3083
  res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
2145
3084
  }
3085
+ else if (url.pathname === '/api/workflows' && req.method === 'DELETE') {
3086
+ const body = await parseBody(req);
3087
+ if (!body.workflow_id) {
3088
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3089
+ res.end(JSON.stringify({ error: 'Missing workflow_id' }));
3090
+ return;
3091
+ }
3092
+ const projectPath = url.searchParams.get('project') || null;
3093
+ const wfFile = filePath('workflows.json', projectPath);
3094
+ let workflows = [];
3095
+ if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
3096
+ const idx = workflows.findIndex(w => w.id === body.workflow_id);
3097
+ if (idx === -1) {
3098
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3099
+ res.end(JSON.stringify({ error: 'Workflow not found' }));
3100
+ return;
3101
+ }
3102
+ const removed = workflows.splice(idx, 1)[0];
3103
+ fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
3104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3105
+ res.end(JSON.stringify({ success: true, removed: removed.name }));
3106
+ }
2146
3107
  else if (url.pathname === '/api/workflows' && req.method === 'POST') {
2147
3108
  const body = await parseBody(req);
2148
3109
  const projectPath = url.searchParams.get('project') || null;
@@ -2168,6 +3129,34 @@ const server = http.createServer(async (req, res) => {
2168
3129
  if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
2169
3130
  wf.updated_at = new Date().toISOString();
2170
3131
  fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
3132
+ } else if (body.action === 'approve' && body.workflow_id && body.step_id !== undefined) {
3133
+ const wf = workflows.find(w => w.id === body.workflow_id);
3134
+ if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
3135
+ const step = wf.steps.find(s => s.id === body.step_id);
3136
+ if (!step || step.status !== 'awaiting_approval') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not awaiting approval' })); return; }
3137
+ if (body.approved) {
3138
+ step.status = 'in_progress';
3139
+ step.started_at = new Date().toISOString();
3140
+ step.approved_at = new Date().toISOString();
3141
+ step.approved_by = '__user__';
3142
+ // Notify assignee via message
3143
+ const messagesFile = filePath('messages.jsonl', projectPath);
3144
+ const historyFile = filePath('history.jsonl', projectPath);
3145
+ 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() };
3146
+ fs.appendFileSync(messagesFile, JSON.stringify(notif) + '\n');
3147
+ fs.appendFileSync(historyFile, JSON.stringify(notif) + '\n');
3148
+ } else {
3149
+ step.status = 'pending';
3150
+ step.rejected_at = new Date().toISOString();
3151
+ step.rejection_feedback = body.feedback || '';
3152
+ const messagesFile = filePath('messages.jsonl', projectPath);
3153
+ const historyFile = filePath('history.jsonl', projectPath);
3154
+ 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() };
3155
+ fs.appendFileSync(messagesFile, JSON.stringify(notif) + '\n');
3156
+ fs.appendFileSync(historyFile, JSON.stringify(notif) + '\n');
3157
+ }
3158
+ wf.updated_at = new Date().toISOString();
3159
+ fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
2171
3160
  } else {
2172
3161
  res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
2173
3162
  }
@@ -2607,65 +3596,6 @@ const server = http.createServer(async (req, res) => {
2607
3596
  }));
2608
3597
  }
2609
3598
 
2610
- // ========== Rules API ==========
2611
-
2612
- else if (url.pathname === '/api/rules' && req.method === 'GET') {
2613
- const projectPath = url.searchParams.get('project') || null;
2614
- const rulesFile = filePath('rules.json', projectPath);
2615
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2616
- res.writeHead(200, { 'Content-Type': 'application/json' });
2617
- res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
2618
- }
2619
-
2620
- else if (url.pathname === '/api/rules' && req.method === 'POST') {
2621
- const projectPath = url.searchParams.get('project') || null;
2622
- const rulesFile = filePath('rules.json', projectPath);
2623
- try {
2624
- const body = await parseBody(req);
2625
- const { text, category } = body;
2626
- if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
2627
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2628
- const rule = {
2629
- id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
2630
- text: text.trim(),
2631
- category: category || 'custom',
2632
- created_by: 'dashboard',
2633
- created_at: new Date().toISOString(),
2634
- active: true,
2635
- };
2636
- rules.push(rule);
2637
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2638
- res.writeHead(201, { 'Content-Type': 'application/json' });
2639
- res.end(JSON.stringify(rule));
2640
- } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
2641
- }
2642
-
2643
- else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
2644
- const projectPath = url.searchParams.get('project') || null;
2645
- const rulesFile = filePath('rules.json', projectPath);
2646
- const ruleId = url.pathname.split('/api/rules/')[1];
2647
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2648
- const idx = rules.findIndex(r => r.id === ruleId);
2649
- if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
2650
- rules.splice(idx, 1);
2651
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2652
- res.writeHead(200, { 'Content-Type': 'application/json' });
2653
- res.end(JSON.stringify({ success: true }));
2654
- }
2655
-
2656
- else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
2657
- const projectPath = url.searchParams.get('project') || null;
2658
- const rulesFile = filePath('rules.json', projectPath);
2659
- const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
2660
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
2661
- const rule = rules.find(r => r.id === ruleId);
2662
- if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
2663
- rule.active = !rule.active;
2664
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
2665
- res.writeHead(200, { 'Content-Type': 'application/json' });
2666
- res.end(JSON.stringify(rule));
2667
- }
2668
-
2669
3599
  // ========== End Rules API ==========
2670
3600
 
2671
3601
  else if (url.pathname === '/api/branches' && req.method === 'GET') {
@@ -2717,6 +3647,91 @@ const server = http.createServer(async (req, res) => {
2717
3647
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2718
3648
  res.end(JSON.stringify(result));
2719
3649
  }
3650
+ // --- Custom Templates CRUD ---
3651
+ else if (url.pathname === '/api/custom-templates' && req.method === 'GET') {
3652
+ const projectPath = url.searchParams.get('project') || null;
3653
+ const templates = readJson(filePath('custom-templates.json', projectPath));
3654
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3655
+ res.end(JSON.stringify(Array.isArray(templates) ? templates : []));
3656
+ }
3657
+ else if (url.pathname === '/api/custom-templates' && req.method === 'POST') {
3658
+ try {
3659
+ const body = await parseBody(req);
3660
+ const projectPath = url.searchParams.get('project') || null;
3661
+ const ctFile = filePath('custom-templates.json', projectPath);
3662
+ const dataDir = resolveDataDir(projectPath);
3663
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
3664
+ await withFileLock(ctFile, () => {
3665
+ const templates = readJson(ctFile);
3666
+ const list = Array.isArray(templates) ? templates : [];
3667
+ const id = body.id || ('custom-' + (body.name || 'template').toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30) + '-' + Date.now().toString(36).slice(-4));
3668
+ if (list.find(t => t.id === id)) {
3669
+ throw new Error('Template with this ID already exists. Use PUT to update.');
3670
+ }
3671
+ const template = {
3672
+ id,
3673
+ name: (body.name || 'Custom Template').substring(0, 100),
3674
+ description: (body.description || '').substring(0, 500),
3675
+ category: body.category || 'custom',
3676
+ conversation_mode: body.conversation_mode || 'direct',
3677
+ source: 'custom',
3678
+ created_at: new Date().toISOString(),
3679
+ updated_at: new Date().toISOString(),
3680
+ agents: Array.isArray(body.agents) ? body.agents.slice(0, 10) : [],
3681
+ ...(body.workflow && { workflow: body.workflow }),
3682
+ };
3683
+ list.push(template);
3684
+ fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
3685
+ });
3686
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3687
+ res.end(JSON.stringify({ success: true }));
3688
+ } catch (e) {
3689
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3690
+ res.end(JSON.stringify({ error: e.message }));
3691
+ }
3692
+ }
3693
+ else if (url.pathname === '/api/custom-templates' && req.method === 'PUT') {
3694
+ try {
3695
+ const body = await parseBody(req);
3696
+ if (!body.id) throw new Error('id required');
3697
+ const projectPath = url.searchParams.get('project') || null;
3698
+ const ctFile = filePath('custom-templates.json', projectPath);
3699
+ await withFileLock(ctFile, () => {
3700
+ const list = readJson(ctFile);
3701
+ if (!Array.isArray(list)) throw new Error('No custom templates found');
3702
+ const idx = list.findIndex(t => t.id === body.id);
3703
+ if (idx === -1) throw new Error('Template not found: ' + body.id);
3704
+ list[idx] = { ...list[idx], ...body, updated_at: new Date().toISOString(), source: 'custom' };
3705
+ fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
3706
+ });
3707
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3708
+ res.end(JSON.stringify({ success: true }));
3709
+ } catch (e) {
3710
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3711
+ res.end(JSON.stringify({ error: e.message }));
3712
+ }
3713
+ }
3714
+ else if (url.pathname === '/api/custom-templates' && req.method === 'DELETE') {
3715
+ try {
3716
+ const body = await parseBody(req);
3717
+ if (!body.id) throw new Error('id required');
3718
+ const projectPath = url.searchParams.get('project') || null;
3719
+ const ctFile = filePath('custom-templates.json', projectPath);
3720
+ await withFileLock(ctFile, () => {
3721
+ const list = readJson(ctFile);
3722
+ if (!Array.isArray(list)) throw new Error('No custom templates found');
3723
+ const idx = list.findIndex(t => t.id === body.id);
3724
+ if (idx === -1) throw new Error('Template not found: ' + body.id);
3725
+ list.splice(idx, 1);
3726
+ fs.writeFileSync(ctFile, JSON.stringify(list, null, 2));
3727
+ });
3728
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3729
+ res.end(JSON.stringify({ success: true }));
3730
+ } catch (e) {
3731
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3732
+ res.end(JSON.stringify({ error: e.message }));
3733
+ }
3734
+ }
2720
3735
  // --- v3.4: Agent Permissions ---
2721
3736
  else if (url.pathname === '/api/permissions' && req.method === 'GET') {
2722
3737
  const projectPath = url.searchParams.get('project') || null;
@@ -2773,14 +3788,28 @@ const server = http.createServer(async (req, res) => {
2773
3788
  }
2774
3789
  // Templates API
2775
3790
  else if (url.pathname === '/api/templates' && req.method === 'GET') {
2776
- const templatesDir = path.join(__dirname, 'templates');
2777
3791
  let templates = [];
3792
+ const templatesDir = path.join(__dirname, 'templates');
2778
3793
  if (fs.existsSync(templatesDir)) {
2779
3794
  templates = fs.readdirSync(templatesDir)
2780
3795
  .filter(f => f.endsWith('.json'))
2781
- .map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
3796
+ .map(f => { try { const t = JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); t.source = 'templates'; return t; } catch { return null; } })
2782
3797
  .filter(Boolean);
2783
3798
  }
3799
+ const convDir = path.join(__dirname, 'conversation-templates');
3800
+ if (fs.existsSync(convDir)) {
3801
+ const conv = fs.readdirSync(convDir)
3802
+ .filter(f => f.endsWith('.json'))
3803
+ .map(f => { try { const t = JSON.parse(fs.readFileSync(path.join(convDir, f), 'utf8')); t.source = 'conversation-templates'; return t; } catch { return null; } })
3804
+ .filter(Boolean);
3805
+ templates = templates.concat(conv);
3806
+ }
3807
+ // Merge custom templates from project data dir
3808
+ const projectPath = url.searchParams.get('project') || null;
3809
+ const customTemplates = readJson(filePath('custom-templates.json', projectPath));
3810
+ if (Array.isArray(customTemplates) && customTemplates.length > 0) {
3811
+ templates = templates.concat(customTemplates);
3812
+ }
2784
3813
  res.writeHead(200, { 'Content-Type': 'application/json' });
2785
3814
  res.end(JSON.stringify(templates));
2786
3815
  }
@@ -2913,6 +3942,8 @@ function startFileWatcher() {
2913
3942
  pendingChangeTypes.add('tasks');
2914
3943
  } else if (filename === 'workflows.json') {
2915
3944
  pendingChangeTypes.add('workflows');
3945
+ } else if (filename === 'hooks.json') {
3946
+ pendingChangeTypes.add('hooks');
2916
3947
  } else {
2917
3948
  pendingChangeTypes.add('update');
2918
3949
  }
@@ -2955,7 +3986,15 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
2955
3986
  console.log(' LAN access: http://' + lanIP + ':' + PORT);
2956
3987
  console.log(' WARNING: LAN mode enabled — accessible to anyone on your network');
2957
3988
  }
2958
- console.log(' Data dir: ' + dataDir);
3989
+ let dataDirLine = ' Data dir: ' + dataDir;
3990
+ if (_defaultDataResolved.source === 'walk-up') {
3991
+ dataDirLine += ' (best .neohive among ancestors — tasks/agents/history)';
3992
+ } else if (_defaultDataResolved.source === 'mcp-config' && _defaultDataResolved.configAt) {
3993
+ dataDirLine += ' (from MCP config under ' + _defaultDataResolved.configAt + ')';
3994
+ } else if (_defaultDataResolved.source === 'environment') {
3995
+ dataDirLine += ' (NEOHIVE_DATA_DIR / NEOHIVE_DATA)';
3996
+ }
3997
+ console.log(dataDirLine);
2959
3998
  console.log(' Projects: ' + getProjects().length + ' registered');
2960
3999
  console.log(' Updates: SSE (real-time) + polling fallback (2s)');
2961
4000
  console.log('');