neohive 6.0.3 → 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,9 @@ 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');
7
10
 
8
11
  function findCursorProjectRootWithNeohive(startDir) {
9
12
  let dir = path.resolve(startDir);
@@ -201,7 +204,11 @@ if (!fs.existsSync(DEFAULT_DATA_DIR) && fs.existsSync(_legacyDir)) {
201
204
  }
202
205
 
203
206
  const HTML_FILE = path.join(__dirname, 'dashboard.html');
204
- 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');
205
212
  const PROJECTS_FILE = path.join(__dirname, 'projects.json');
206
213
 
207
214
  // --- Multi-project support ---
@@ -212,7 +219,12 @@ function getProjects() {
212
219
  }
213
220
 
214
221
  function saveProjects(projects) {
215
- 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
+ }
216
228
  }
217
229
 
218
230
  // Multi-project paths must be the repo root, not .../project/.neohive (otherwise we join .neohive twice).
@@ -442,6 +454,7 @@ function apiAgents(query) {
442
454
  const hb = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
443
455
  if (hb.last_activity) agents[name].last_activity = hb.last_activity;
444
456
  if (hb.pid) agents[name].pid = hb.pid;
457
+ if (hb.ppid) agents[name].ppid = hb.ppid;
445
458
  } catch {}
446
459
  }
447
460
  }
@@ -459,16 +472,46 @@ function apiAgents(query) {
459
472
  const alive = isPidAlive(info.pid, info.last_activity);
460
473
  const lastActivity = info.last_activity || info.timestamp;
461
474
  const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
475
+ const hasHeartbeat = fs.existsSync(path.join(resolveDataDir(projectPath), `heartbeat-${name}.json`));
462
476
  const profile = profiles[name] || {};
463
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
+
464
505
  result[name] = {
465
506
  pid: info.pid,
507
+ ppid: info.ppid || null,
466
508
  alive,
467
509
  registered_at: info.timestamp,
468
510
  last_activity: lastActivity,
469
511
  last_message: lastMessageTime[name] || null,
470
512
  idle_seconds: alive ? idleSeconds : null,
471
- status: !alive ? 'offline' : (info.listening_since && alive) ? 'listening' : idleSeconds > 30 ? 'idle' : 'working',
513
+ last_listened_at: info.last_listened_at || null,
514
+ status,
472
515
  listening_since: info.listening_since || null,
473
516
  is_listening: !!(info.listening_since && alive),
474
517
  provider: info.provider || 'unknown',
@@ -480,6 +523,7 @@ function apiAgents(query) {
480
523
  appearance: profile.appearance || {},
481
524
  hostname: info.hostname || null,
482
525
  is_remote: !isLocal && alive,
526
+ platform_skills: (cards && cards[name] && cards[name].platform_skills) || [],
483
527
  skills: (cards && cards[name] && cards[name].skills) || [],
484
528
  };
485
529
  // Include workspace status for agent intent board
@@ -490,6 +534,10 @@ function apiAgents(query) {
490
534
  if (ws._status) result[name].current_status = ws._status;
491
535
  }
492
536
  } catch {}
537
+
538
+ const dataDir = resolveDataDir(projectPath);
539
+ const ide = readIdeActivity(dataDir, name);
540
+ if (ide) applyIdeActivityHint(result[name], ide, { dataDir, agentName: name });
493
541
  }
494
542
  return result;
495
543
  }
@@ -647,6 +695,44 @@ function generateNotifications(currentAgents) {
647
695
  }
648
696
 
649
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
+
650
736
  // Pricing per 1M tokens (USD)
651
737
  const TOKEN_PRICING = {
652
738
  'claude-opus-4-6': { input: 15.00, output: 75.00, cache_write: 18.75, cache_read: 1.50 },
@@ -687,33 +773,109 @@ function parseSessionUsage(sessionFile, maxBytes) {
687
773
  return usage;
688
774
  }
689
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
+
690
800
  function apiTokenUsage(query) {
691
801
  const projectPath = query.get('project') || null;
692
802
  const dataDir = resolveDataDir(projectPath);
693
803
  const agents = readJson(filePath('agents.json', projectPath));
694
804
  const home = os.homedir();
695
805
  const sessionsDir = path.join(home, '.claude', 'sessions');
696
- // Build project slug matching Claude Code's format
697
806
  const projectAbsPath = projectPath ? path.resolve(projectPath) : path.resolve(process.cwd());
698
807
  const projectSlug = projectAbsPath.replace(/\//g, '-');
699
808
  const projectSessionDir = path.join(home, '.claude', 'projects', projectSlug);
700
809
 
701
810
  const result = { agents: {}, total_cost_usd: 0, total_tokens: 0 };
702
811
 
812
+ const agentSessions = {};
813
+ const claimedFiles = new Set();
814
+
703
815
  for (const [name, info] of Object.entries(agents)) {
704
816
  if (!info.pid) continue;
705
817
  try {
706
- // Map CLI PID (ppid) → session ID session file. Fall back to pid if ppid not available.
707
- const cliPid = info.ppid || info.pid;
708
- const pidFile = path.join(sessionsDir, cliPid + '.json');
709
- if (!fs.existsSync(pidFile)) continue;
710
- const session = readJson(pidFile);
711
- if (!session || !session.sessionId) continue;
712
- const sessionFile = path.join(projectSessionDir, session.sessionId + '.jsonl');
713
- if (!fs.existsSync(sessionFile)) continue;
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
+ }
714
873
 
874
+ // Phase 3: compute usage + cost
875
+ for (const [name, sessionFile] of Object.entries(agentSessions)) {
876
+ try {
877
+ const info = agents[name];
715
878
  const usage = parseSessionUsage(sessionFile);
716
- // Calculate cost
717
879
  const pricing = TOKEN_PRICING[usage.model] || TOKEN_PRICING['claude-opus-4-6'];
718
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;
719
881
 
@@ -730,7 +892,7 @@ function apiTokenUsage(query) {
730
892
  };
731
893
  result.total_cost_usd += cost;
732
894
  result.total_tokens += result.agents[name].total_tokens;
733
- } catch { /* skip agents without session data */ }
895
+ } catch { /* skip */ }
734
896
  }
735
897
  result.total_cost_usd = Math.round(result.total_cost_usd * 100) / 100;
736
898
  return result;
@@ -1076,6 +1238,9 @@ function apiLoadConversation(query) {
1076
1238
  return { success: true };
1077
1239
  }
1078
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
+
1079
1244
  // Inject a message from the dashboard (system message or nudge to an agent)
1080
1245
  function apiInjectMessage(body, query) {
1081
1246
  const projectPath = query.get('project') || null;
@@ -1095,10 +1260,39 @@ function apiInjectMessage(body, query) {
1095
1260
  return { error: 'Invalid agent name' };
1096
1261
  }
1097
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
+
1098
1274
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
1099
- const fromName = '__user__';
1100
1275
  const now = new Date().toISOString();
1101
1276
 
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
+
1102
1296
  // Broadcast to all agents — single __group__ message instead of per-agent
1103
1297
  if (body.to === '__all__') {
1104
1298
  const msg = {
@@ -1214,7 +1408,11 @@ function apiAddProject(body) {
1214
1408
  ensureMCPConfig('cursor', serverPath, absPath);
1215
1409
 
1216
1410
  projects.push({ name, path: absPath, added_at: new Date().toISOString() });
1217
- saveProjects(projects);
1411
+ try {
1412
+ saveProjects(projects);
1413
+ } catch (e) {
1414
+ return { error: 'Failed to save project: ' + e.message };
1415
+ }
1218
1416
  return { success: true, project: { name, path: absPath } };
1219
1417
  }
1220
1418
 
@@ -1225,7 +1423,11 @@ function apiRemoveProject(body) {
1225
1423
  const before = projects.length;
1226
1424
  projects = projects.filter(p => normalizeMonitoredProjectRoot(path.resolve(p.path)) !== absPath);
1227
1425
  if (projects.length === before) return { error: 'Project not found' };
1228
- saveProjects(projects);
1426
+ try {
1427
+ saveProjects(projects);
1428
+ } catch (e) {
1429
+ return { error: 'Failed to save project changes: ' + e.message };
1430
+ }
1229
1431
  return { success: true };
1230
1432
  }
1231
1433
 
@@ -1425,6 +1627,26 @@ function apiRules(query) {
1425
1627
  try { return JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch { return []; }
1426
1628
  }
1427
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
+
1428
1650
  function apiAddRule(body, query) {
1429
1651
  const projectPath = query.get('project') || null;
1430
1652
  const rulesFile = filePath('rules.json', projectPath);
@@ -1436,11 +1658,15 @@ function apiAddRule(body, query) {
1436
1658
  try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
1437
1659
  }
1438
1660
 
1661
+ const parsedScope = parseScope(body.scope);
1439
1662
  const rule = {
1440
1663
  id: 'rule_' + crypto.randomBytes(6).toString('hex'),
1441
1664
  text: body.text.trim(),
1442
1665
  category: body.category || 'general',
1443
1666
  priority: body.priority || 'normal',
1667
+ scope_role: parsedScope.role,
1668
+ scope_provider: parsedScope.provider,
1669
+ scope_agent: parsedScope.agent,
1444
1670
  created_by: body.created_by || 'Dashboard',
1445
1671
  created_at: new Date().toISOString(),
1446
1672
  active: true
@@ -1466,6 +1692,12 @@ function apiUpdateRule(body, query) {
1466
1692
  if (body.text !== undefined) rule.text = body.text.trim();
1467
1693
  if (body.category !== undefined) rule.category = body.category;
1468
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
+ }
1469
1701
  if (body.active !== undefined) rule.active = body.active;
1470
1702
  rule.updated_at = new Date().toISOString();
1471
1703
 
@@ -1491,6 +1723,63 @@ function apiDeleteRule(body, query) {
1491
1723
  return { success: true };
1492
1724
  }
1493
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
+
1494
1783
  // Auto-discover .neohive directories nearby
1495
1784
  function apiDiscover() {
1496
1785
  const found = [];
@@ -1539,6 +1828,11 @@ function apiDiscover() {
1539
1828
 
1540
1829
  // --- Agent Launcher ---
1541
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
+
1542
1836
  function ensureMCPConfig(cli, serverPath, projectDir) {
1543
1837
  const abDir = path.join(projectDir, '.neohive').replace(/\\/g, '/');
1544
1838
  if (cli === 'claude') {
@@ -1548,7 +1842,7 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1548
1842
  try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
1549
1843
  }
1550
1844
  if (!mcpConfig.mcpServers['neohive']) {
1551
- 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 } };
1552
1846
  fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
1553
1847
  }
1554
1848
  } else if (cli === 'gemini') {
@@ -1560,7 +1854,7 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1560
1854
  try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); if (!settings.mcpServers) settings.mcpServers = {}; } catch {}
1561
1855
  }
1562
1856
  if (!settings.mcpServers['neohive']) {
1563
- 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 } };
1564
1858
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1565
1859
  }
1566
1860
  } else if (cli === 'codex') {
@@ -1569,10 +1863,16 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1569
1863
  if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
1570
1864
  let config = '';
1571
1865
  if (fs.existsSync(configPath)) config = fs.readFileSync(configPath, 'utf8');
1572
- if (!config.includes('[mcp_servers.neohive]')) {
1573
- config += `\n[mcp_servers.neohive]\ncommand = "node"\nargs = [${JSON.stringify(serverPath)}]\n\n[mcp_servers.neohive.env]\nNEOHIVE_DATA_DIR = ${JSON.stringify(abDir)}\n`;
1574
- fs.writeFileSync(configPath, config);
1575
- }
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);
1576
1876
  } else if (cli === 'cursor') {
1577
1877
  const cursorDir = path.join(projectDir, '.cursor');
1578
1878
  const mcpConfigPath = path.join(cursorDir, 'mcp.json');
@@ -1583,7 +1883,7 @@ function ensureMCPConfig(cli, serverPath, projectDir) {
1583
1883
  }
1584
1884
  if (!mcpConfig.mcpServers['neohive']) {
1585
1885
  mcpConfig.mcpServers['neohive'] = {
1586
- command: 'node',
1886
+ command: mcpNodeCommand(),
1587
1887
  args: [serverPath],
1588
1888
  env: { NEOHIVE_DATA_DIR: abDir },
1589
1889
  timeout: 300,
@@ -1926,6 +2226,57 @@ setInterval(() => {
1926
2226
  }
1927
2227
  }, 300000).unref(); // Clean every 5 minutes, .unref() prevents zombie process
1928
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
+
1929
2280
  const server = http.createServer(async (req, res) => {
1930
2281
  const url = new URL(req.url, 'http://localhost:' + PORT);
1931
2282
 
@@ -2059,11 +2410,35 @@ const server = http.createServer(async (req, res) => {
2059
2410
  return;
2060
2411
  }
2061
2412
 
2062
- // Serve logo image
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
+
2063
2438
  if (url.pathname === '/logo.png') {
2064
2439
  if (fs.existsSync(LOGO_FILE)) {
2065
2440
  const logo = fs.readFileSync(LOGO_FILE);
2066
- 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' });
2067
2442
  res.end(logo);
2068
2443
  } else {
2069
2444
  res.writeHead(404);
@@ -2072,12 +2447,46 @@ const server = http.createServer(async (req, res) => {
2072
2447
  return;
2073
2448
  }
2074
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
+
2075
2484
  // Serve dashboard HTML (always re-read for hot reload)
2076
2485
  if (url.pathname === '/' || url.pathname === '/index.html') {
2077
2486
  const html = fs.readFileSync(HTML_FILE, 'utf8');
2078
2487
  res.writeHead(200, {
2079
2488
  'Content-Type': 'text/html; charset=utf-8',
2080
- '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'",
2081
2490
  'X-Frame-Options': 'DENY',
2082
2491
  'X-Content-Type-Options': 'nosniff',
2083
2492
  'Referrer-Policy': 'no-referrer',
@@ -2087,26 +2496,11 @@ const server = http.createServer(async (req, res) => {
2087
2496
  });
2088
2497
  res.end(html);
2089
2498
  }
2090
- // Existing APIs (now with ?project= param support)
2091
- else if (url.pathname === '/api/history' && req.method === 'GET') {
2092
- res.writeHead(200, { 'Content-Type': 'application/json' });
2093
- res.end(JSON.stringify(apiHistory(url.searchParams)));
2094
- }
2095
- else if (url.pathname === '/api/agents' && req.method === 'GET') {
2096
- const payload = apiAgents(url.searchParams);
2097
- res.writeHead(200, { 'Content-Type': 'application/json' });
2098
- res.end(JSON.stringify(payload));
2099
- }
2100
- else if (url.pathname === '/api/channels' && req.method === 'GET') {
2101
- res.writeHead(200, { 'Content-Type': 'application/json' });
2102
- res.end(JSON.stringify(apiChannels(url.searchParams)));
2103
- }
2104
- else if (url.pathname === '/api/decisions' && req.method === 'GET') {
2105
- const projectPath = url.searchParams.get('project') || null;
2106
- const decisions = readJson(filePath('decisions.json', projectPath));
2107
- res.writeHead(200, { 'Content-Type': 'application/json' });
2108
- 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);
2109
2502
  }
2503
+ // ── Complex routes (body parsing, SSE, multi-step logic) ──────────────────
2110
2504
  else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
2111
2505
  const body = await parseBody(req);
2112
2506
  if (!body.name) {
@@ -2489,6 +2883,10 @@ const server = http.createServer(async (req, res) => {
2489
2883
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
2490
2884
  res.end(JSON.stringify(result));
2491
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
+ }
2492
2890
  else if (url.pathname === '/api/search' && req.method === 'GET') {
2493
2891
  const projectPath = url.searchParams.get('project') || null;
2494
2892
  const query = (url.searchParams.get('q') || '').trim();
@@ -2576,6 +2974,29 @@ const server = http.createServer(async (req, res) => {
2576
2974
  res.writeHead(200, { 'Content-Type': 'application/json' });
2577
2975
  res.end(JSON.stringify(apiDiscover()));
2578
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
+ }
2579
3000
  // --- v3.0 API endpoints ---
2580
3001
  else if (url.pathname === '/api/profiles' && req.method === 'GET') {
2581
3002
  const projectPath = url.searchParams.get('project') || null;
@@ -2601,6 +3022,29 @@ const server = http.createServer(async (req, res) => {
2601
3022
  res.writeHead(200, { 'Content-Type': 'application/json' });
2602
3023
  res.end(JSON.stringify({ success: true }));
2603
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
+ }
2604
3048
  else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
2605
3049
  const projectPath = url.searchParams.get('project') || null;
2606
3050
  const agentParam = url.searchParams.get('agent');
@@ -3152,65 +3596,6 @@ const server = http.createServer(async (req, res) => {
3152
3596
  }));
3153
3597
  }
3154
3598
 
3155
- // ========== Rules API ==========
3156
-
3157
- else if (url.pathname === '/api/rules' && req.method === 'GET') {
3158
- const projectPath = url.searchParams.get('project') || null;
3159
- const rulesFile = filePath('rules.json', projectPath);
3160
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
3161
- res.writeHead(200, { 'Content-Type': 'application/json' });
3162
- res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
3163
- }
3164
-
3165
- else if (url.pathname === '/api/rules' && req.method === 'POST') {
3166
- const projectPath = url.searchParams.get('project') || null;
3167
- const rulesFile = filePath('rules.json', projectPath);
3168
- try {
3169
- const body = await parseBody(req);
3170
- const { text, category } = body;
3171
- if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
3172
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
3173
- const rule = {
3174
- id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
3175
- text: text.trim(),
3176
- category: category || 'custom',
3177
- created_by: 'dashboard',
3178
- created_at: new Date().toISOString(),
3179
- active: true,
3180
- };
3181
- rules.push(rule);
3182
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
3183
- res.writeHead(201, { 'Content-Type': 'application/json' });
3184
- res.end(JSON.stringify(rule));
3185
- } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
3186
- }
3187
-
3188
- else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
3189
- const projectPath = url.searchParams.get('project') || null;
3190
- const rulesFile = filePath('rules.json', projectPath);
3191
- const ruleId = url.pathname.split('/api/rules/')[1];
3192
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
3193
- const idx = rules.findIndex(r => r.id === ruleId);
3194
- if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
3195
- rules.splice(idx, 1);
3196
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
3197
- res.writeHead(200, { 'Content-Type': 'application/json' });
3198
- res.end(JSON.stringify({ success: true }));
3199
- }
3200
-
3201
- else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
3202
- const projectPath = url.searchParams.get('project') || null;
3203
- const rulesFile = filePath('rules.json', projectPath);
3204
- const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
3205
- const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
3206
- const rule = rules.find(r => r.id === ruleId);
3207
- if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
3208
- rule.active = !rule.active;
3209
- fs.writeFileSync(rulesFile, JSON.stringify(rules));
3210
- res.writeHead(200, { 'Content-Type': 'application/json' });
3211
- res.end(JSON.stringify(rule));
3212
- }
3213
-
3214
3599
  // ========== End Rules API ==========
3215
3600
 
3216
3601
  else if (url.pathname === '/api/branches' && req.method === 'GET') {
@@ -3557,6 +3942,8 @@ function startFileWatcher() {
3557
3942
  pendingChangeTypes.add('tasks');
3558
3943
  } else if (filename === 'workflows.json') {
3559
3944
  pendingChangeTypes.add('workflows');
3945
+ } else if (filename === 'hooks.json') {
3946
+ pendingChangeTypes.add('hooks');
3560
3947
  } else {
3561
3948
  pendingChangeTypes.add('update');
3562
3949
  }