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/CHANGELOG.md +262 -77
- package/README.md +66 -63
- package/SECURITY.md +8 -6
- package/cli.js +268 -33
- package/dashboard.html +2269 -546
- package/dashboard.js +492 -105
- package/design-system.css +708 -0
- package/design-system.html +264 -0
- package/lib/agents.js +20 -6
- package/lib/audit.js +417 -0
- package/lib/codex-neohive-toml.js +34 -0
- package/lib/github-sync.js +291 -0
- package/lib/hooks.js +173 -0
- package/lib/ide-activity.js +121 -0
- package/logo.svg +1 -0
- package/package.json +11 -2
- package/scripts/check-portable-paths.mjs +74 -0
- package/server.js +1148 -743
- package/tools/channels.js +116 -0
- package/tools/governance.js +471 -0
- package/tools/hooks.js +65 -0
- package/tools/knowledge.js +301 -0
- package/tools/messaging.js +321 -0
- package/tools/safety.js +144 -0
- package/tools/system.js +198 -0
- package/tools/tasks.js +446 -0
- package/tools/workflows.js +286 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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:
|
|
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
|
|
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/
|
|
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
|
-
//
|
|
2091
|
-
else if (
|
|
2092
|
-
|
|
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
|
}
|