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/CHANGELOG.md +269 -77
- package/README.md +66 -63
- package/SECURITY.md +8 -6
- package/cli.js +377 -35
- package/conversation-templates/autonomous-feature.json +54 -4
- package/conversation-templates/code-review.json +41 -3
- package/conversation-templates/debug-squad.json +41 -3
- package/conversation-templates/feature-build.json +41 -3
- package/conversation-templates/research-write.json +41 -3
- package/dashboard.html +3954 -921
- package/dashboard.js +1192 -153
- 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/compact.js +5 -2
- package/lib/config.js +4 -3
- package/lib/file-io.js +3 -3
- package/lib/github-sync.js +291 -0
- package/lib/hooks.js +173 -0
- package/lib/ide-activity.js +121 -0
- package/lib/resolve-server-data-dir.js +96 -0
- package/logo.svg +1 -0
- package/package.json +12 -3
- package/scripts/check-portable-paths.mjs +74 -0
- package/server.js +1986 -857
- package/templates/debate.json +24 -5
- package/templates/managed.json +48 -9
- package/templates/pair.json +22 -3
- package/templates/review.json +26 -5
- package/templates/team.json +38 -8
- package/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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
76
|
-
function
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
81
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
315
|
+
const STALE_THRESHOLD = 30000; // 30s — 3x 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='%
|
|
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='%
|
|
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='%
|
|
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='%
|
|
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='%
|
|
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);
|
|
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
|
-
|
|
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 >
|
|
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 = ['#
|
|
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] || '#
|
|
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
|
-
//
|
|
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
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
};
|
|
862
|
-
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
863
|
-
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
864
|
-
ids.push(msg.id);
|
|
865
|
-
}
|
|
866
|
-
return { success: true, messageIds: ids, broadcast: true };
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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='%
|
|
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,#
|
|
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:#
|
|
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:#
|
|
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> · BSL 1.1</div>
|
|
1006
1509
|
<script>
|
|
1007
|
-
var COLORS=['#
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
|
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
|
-
//
|
|
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/
|
|
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
|
-
//
|
|
1730
|
-
else if (
|
|
1731
|
-
|
|
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 {
|
|
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
|
-
|
|
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('');
|