@monoes/monomindcli 1.13.0 → 1.14.1
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/.claude/agents/generated/churn-analyst.md +53 -0
- package/.claude/agents/generated/code-reviewer.md +55 -0
- package/.claude/agents/generated/code-validator.md +57 -0
- package/.claude/agents/generated/complexity-scanner.md +56 -0
- package/.claude/agents/generated/devbot-orchestrator.md +58 -0
- package/.claude/agents/generated/devbot-planner.md +63 -0
- package/.claude/agents/generated/impact-assessor.md +54 -0
- package/.claude/commands/mastermind/master.md +88 -24
- package/.claude/helpers/control-start.cjs +60 -1
- package/.claude/helpers/event-logger.cjs +43 -2
- package/.claude/helpers/handlers/capture-handler.cjs +336 -0
- package/.claude/helpers/handlers/route-handler.cjs +11 -11
- package/.claude/helpers/hook-handler.cjs +17 -1
- package/.claude/helpers/session.cjs +20 -2
- package/.claude/skills/mastermind/createorg.md +227 -16
- package/.claude/skills/mastermind/idea.md +15 -3
- package/.claude/skills/mastermind/runorg.md +2 -1
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/org.d.ts +4 -0
- package/dist/src/commands/org.d.ts.map +1 -0
- package/dist/src/commands/org.js +93 -0
- package/dist/src/commands/org.js.map +1 -0
- package/dist/src/mcp-tools/memory-tools.js +6 -6
- package/dist/src/mcp-tools/memory-tools.js.map +1 -1
- package/dist/src/mcp-tools/session-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/session-tools.js +9 -10
- package/dist/src/mcp-tools/session-tools.js.map +1 -1
- package/dist/src/mcp-tools/task-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/task-tools.js +7 -8
- package/dist/src/mcp-tools/task-tools.js.map +1 -1
- package/dist/src/mcp-tools/types.d.ts +1 -0
- package/dist/src/mcp-tools/types.d.ts.map +1 -1
- package/dist/src/mcp-tools/types.js +49 -0
- package/dist/src/mcp-tools/types.js.map +1 -1
- package/dist/src/ui/dashboard.html +1639 -249
- package/dist/src/ui/orgs.html +1 -0
- package/dist/src/ui/server.mjs +402 -132
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
package/dist/src/ui/server.mjs
CHANGED
|
@@ -185,6 +185,8 @@ const sseClients = new Set();
|
|
|
185
185
|
const mmSseClients = new Set();
|
|
186
186
|
// Active org run tracking: org -> runId (enables event routing for orgs without runId in payload)
|
|
187
187
|
const activeOrgRuns = new Map();
|
|
188
|
+
// Active session tracking: org -> {sessionId, ts} (enables linking agent events to sessions)
|
|
189
|
+
const activeSessionsByOrg = new Map();
|
|
188
190
|
|
|
189
191
|
// Returns the shared git directory parent so run files survive branch switches and
|
|
190
192
|
// are shared across all worktrees. In a worktree, .git is a FILE pointing to the
|
|
@@ -363,6 +365,207 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
363
365
|
return out;
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
// ── handleMastermindEvent ─────────────────────────────────────────────────
|
|
369
|
+
// Extracted from the request dispatcher to reduce cyclomatic complexity.
|
|
370
|
+
// Handles POST /api/mastermind/event: parses body, enriches with runId/session,
|
|
371
|
+
// persists to JSONL files, broadcasts to SSE clients, returns {ok:true}.
|
|
372
|
+
async function handleMastermindEvent(req, res) {
|
|
373
|
+
let body = '';
|
|
374
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
375
|
+
let event = {};
|
|
376
|
+
try { event = JSON.parse(body); } catch (_) {}
|
|
377
|
+
event.ts = event.ts || Date.now();
|
|
378
|
+
// Use project path from event if provided (multi-project support).
|
|
379
|
+
// Security: path.isAbsolute() alone is insufficient — an attacker can
|
|
380
|
+
// supply event.project="/etc" and cause writes to system directories.
|
|
381
|
+
// Only accept paths that resolve to an existing directory AND are not
|
|
382
|
+
// the filesystem root (/), AND are not obviously system paths.
|
|
383
|
+
// Cap to 4096 chars to prevent OOM from huge path strings.
|
|
384
|
+
const _rawProject = event.project;
|
|
385
|
+
let eventProject = null;
|
|
386
|
+
if (typeof _rawProject === 'string' && _rawProject.length > 0 && _rawProject.length <= 4096
|
|
387
|
+
&& path.isAbsolute(_rawProject)) {
|
|
388
|
+
// Reject filesystem root and common system directories
|
|
389
|
+
const _norm = path.resolve(_rawProject);
|
|
390
|
+
const _systemPaths = ['/', '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/dev', '/sys', '/proc', '/tmp'];
|
|
391
|
+
if (!_systemPaths.includes(_norm) && !_systemPaths.some(p => _norm.startsWith(p + '/'))) {
|
|
392
|
+
eventProject = _norm;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const root = eventProject || projectDir || process.cwd();
|
|
396
|
+
const dataDir = path.join(root, 'data');
|
|
397
|
+
try { fs.mkdirSync(dataDir, { recursive: true }); } catch (_) {}
|
|
398
|
+
// Track known project dirs for aggregated session listing
|
|
399
|
+
if (eventProject) {
|
|
400
|
+
const knownFile = path.join(projectDir || process.cwd(), 'data', 'known-projects.json');
|
|
401
|
+
try {
|
|
402
|
+
let known = [];
|
|
403
|
+
try { known = JSON.parse(fs.readFileSync(knownFile, 'utf8')); } catch (_) {}
|
|
404
|
+
if (!known.includes(eventProject)) { known.push(eventProject); fs.writeFileSync(knownFile, JSON.stringify(known)); }
|
|
405
|
+
} catch (_) {}
|
|
406
|
+
}
|
|
407
|
+
// Track active runs and enrich event with runId BEFORE persisting so the JSONL replay
|
|
408
|
+
// on SSE reconnect contains the same enriched event that live clients received.
|
|
409
|
+
// Previously this was done AFTER the appendFileSync, causing org:comms events stored in
|
|
410
|
+
// mastermind-events.jsonl to lack runId — _odtHandleLiveEvent dropped them on reconnect.
|
|
411
|
+
if (event.org) {
|
|
412
|
+
const _orgKey = String(event.org).trim();
|
|
413
|
+
// Any event with both org+runId updates the active run map (run:start written directly to file so org:start is first via curl)
|
|
414
|
+
if (event.runId) activeOrgRuns.set(_orgKey, String(event.runId).trim());
|
|
415
|
+
else if (activeOrgRuns.has(_orgKey)) event.runId = activeOrgRuns.get(_orgKey);
|
|
416
|
+
if (event.type === 'run:complete' || event.type === 'org:complete') activeOrgRuns.delete(_orgKey);
|
|
417
|
+
// Persist active-run.json so capture-handler.cjs can find the current org/runId without HTTP calls
|
|
418
|
+
try {
|
|
419
|
+
const _captureDir = path.join(root, '.monomind', 'capture');
|
|
420
|
+
const _activeRunFile = path.join(_captureDir, 'active-run.json');
|
|
421
|
+
if (event.type === 'run:start' && event.org && event.runId) {
|
|
422
|
+
fs.mkdirSync(_captureDir, { recursive: true });
|
|
423
|
+
fs.writeFileSync(_activeRunFile, JSON.stringify({ org: String(event.org).trim(), runId: String(event.runId).trim(), ts: Date.now() }));
|
|
424
|
+
} else if ((event.type === 'run:complete' || event.type === 'org:complete') && fs.existsSync(_activeRunFile)) {
|
|
425
|
+
fs.unlinkSync(_activeRunFile);
|
|
426
|
+
}
|
|
427
|
+
} catch(_e) {}
|
|
428
|
+
}
|
|
429
|
+
try { fs.appendFileSync(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(event) + '\n'); } catch (_) {}
|
|
430
|
+
// Persist to git-safe run file (survives branch switches + shared across worktrees)
|
|
431
|
+
if (event.org && event.runId) {
|
|
432
|
+
try {
|
|
433
|
+
const _orn = String(event.org).trim();
|
|
434
|
+
const _rid = String(event.runId).trim();
|
|
435
|
+
if (_orn.length > 0 && _orn.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(_orn)
|
|
436
|
+
&& _rid.length > 0 && _rid.length <= 80 && /^[a-z0-9][a-z0-9_-]*$/i.test(_rid)) {
|
|
437
|
+
const _monoDir = _getGitMonomindDir(root) || path.join(root, '.monomind');
|
|
438
|
+
const _runDir = path.join(_monoDir, 'orgs', _orn, 'runs');
|
|
439
|
+
fs.mkdirSync(_runDir, { recursive: true });
|
|
440
|
+
fs.appendFileSync(path.join(_runDir, `${_rid}.jsonl`), JSON.stringify(event) + '\n');
|
|
441
|
+
// agent:usage — persist per-role token/cost data to state.json (accumulated across runs)
|
|
442
|
+
if (event.type === 'agent:usage' && event.role) {
|
|
443
|
+
try {
|
|
444
|
+
const _arole = String(event.role).trim();
|
|
445
|
+
if (_arole.length > 0 && _arole.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(_arole)) {
|
|
446
|
+
const _stateFile = path.join(root, '.monomind', 'orgs', `${_orn}-state.json`);
|
|
447
|
+
let _st = {};
|
|
448
|
+
try { _st = JSON.parse(fs.readFileSync(_stateFile, 'utf8')); } catch(_e) {}
|
|
449
|
+
if (!_st.agents) _st.agents = {};
|
|
450
|
+
const _ex = _st.agents[_arole] || {};
|
|
451
|
+
_st.agents[_arole] = {
|
|
452
|
+
..._ex,
|
|
453
|
+
tokens_in: (_ex.tokens_in || 0) + (Number(event.tokens_in) || 0),
|
|
454
|
+
tokens_out: (_ex.tokens_out || 0) + (Number(event.tokens_out) || 0),
|
|
455
|
+
total_cost_usd: (_ex.total_cost_usd || 0) + (Number(event.cost_usd) || 0),
|
|
456
|
+
lastUpdated: event.ts,
|
|
457
|
+
};
|
|
458
|
+
fs.writeFileSync(_stateFile, JSON.stringify(_st, null, 2));
|
|
459
|
+
}
|
|
460
|
+
} catch(_e) {}
|
|
461
|
+
}
|
|
462
|
+
// Solution 3: dedicated conversation log — org:comms only, for easy replay
|
|
463
|
+
if (event.type === 'org:comms') {
|
|
464
|
+
const _conv = { ts: event.ts, run_id: _rid, from: event.from, to: event.to, msg: event.msg };
|
|
465
|
+
fs.appendFileSync(path.join(_runDir, `${_rid}.convs.jsonl`), JSON.stringify(_conv) + '\n');
|
|
466
|
+
// Also write to org-level threads.jsonl so the dashboard Threads tab shows agent conversations
|
|
467
|
+
const _orgThreadsFile = path.join(root, '.monomind', 'orgs', `${_orn}-threads.jsonl`);
|
|
468
|
+
const _thread = { type: 'message', id: `${_rid}-${event.ts}`, run_id: _rid, ts: event.ts, from: event.from, to: event.to, msg: event.msg, subject: `Run ${_rid}` };
|
|
469
|
+
try { fs.appendFileSync(_orgThreadsFile, JSON.stringify(_thread) + '\n'); } catch(_) {}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch (_) {}
|
|
473
|
+
}
|
|
474
|
+
// ── Active session tracking: link org:comms / agent:usage events to current session ──
|
|
475
|
+
// This must run BEFORE session persistence so events without session get enriched.
|
|
476
|
+
try {
|
|
477
|
+
const _evOrg = event.org ? String(event.org).trim() : null;
|
|
478
|
+
if (event.type === 'session:start' && event.session && _evOrg) {
|
|
479
|
+
activeSessionsByOrg.set(_evOrg, { sessionId: String(event.session), ts: event.ts || Date.now() });
|
|
480
|
+
// Write active-session.json so capture-handler.cjs can read it without HTTP
|
|
481
|
+
try {
|
|
482
|
+
const _captureDir = path.join(root, '.monomind', 'capture');
|
|
483
|
+
fs.mkdirSync(_captureDir, { recursive: true });
|
|
484
|
+
fs.writeFileSync(path.join(_captureDir, 'active-session.json'),
|
|
485
|
+
JSON.stringify({ org: _evOrg, sessionId: String(event.session), ts: Date.now() }));
|
|
486
|
+
} catch(_) {}
|
|
487
|
+
} else if (event.type === 'session:complete' && _evOrg) {
|
|
488
|
+
activeSessionsByOrg.delete(_evOrg);
|
|
489
|
+
try { fs.unlinkSync(path.join(root, '.monomind', 'capture', 'active-session.json')); } catch(_) {}
|
|
490
|
+
}
|
|
491
|
+
// Enrich events that have org but no session (agent:usage, org:comms, agent:spawn, intercom)
|
|
492
|
+
if (_evOrg && !event.session && activeSessionsByOrg.has(_evOrg)) {
|
|
493
|
+
event.session = activeSessionsByOrg.get(_evOrg).sessionId;
|
|
494
|
+
}
|
|
495
|
+
} catch(_) {}
|
|
496
|
+
// ── Per-session JSONL persistence (append-only, O(1) per event) ──────────
|
|
497
|
+
// Replaces the old monolithic mastermind-sessions.json (O(N) read+write per event).
|
|
498
|
+
// Format: data/sessions/<sessionId>.jsonl + data/sessions/_index.json
|
|
499
|
+
try {
|
|
500
|
+
const _sid = String(event.session || '').trim();
|
|
501
|
+
if (_sid.length > 0 && _sid.length <= 128 && /^[a-zA-Z0-9_.-]+$/.test(_sid)) {
|
|
502
|
+
const sessDir = path.join(dataDir, 'sessions');
|
|
503
|
+
fs.mkdirSync(sessDir, { recursive: true });
|
|
504
|
+
// Append event to per-session JSONL (O(1), no read)
|
|
505
|
+
fs.appendFileSync(path.join(sessDir, `${_sid}.jsonl`), JSON.stringify(event) + '\n');
|
|
506
|
+
// Update lightweight index (id, ts, prompt, status, org, startedAt, endedAt, domains only)
|
|
507
|
+
const indexFile = path.join(sessDir, '_index.json');
|
|
508
|
+
let _idx = [];
|
|
509
|
+
try { _idx = JSON.parse(fs.readFileSync(indexFile, 'utf8')); } catch(_) {}
|
|
510
|
+
const _entry = _idx.find(e => e.id === _sid);
|
|
511
|
+
if (event.type === 'session:start') {
|
|
512
|
+
if (!_entry) {
|
|
513
|
+
_idx.unshift({ id: _sid, ts: event.ts, prompt: event.prompt || '', status: 'running',
|
|
514
|
+
org: event.org || '', startedAt: event.ts, domains: [] });
|
|
515
|
+
if (_idx.length > 2000) _idx = _idx.slice(0, 2000);
|
|
516
|
+
}
|
|
517
|
+
} else if (_entry) {
|
|
518
|
+
if (event.type === 'session:complete') { _entry.status = event.status || 'complete'; _entry.endedAt = event.ts; }
|
|
519
|
+
if (event.type === 'domain:dispatch' && event.domain) {
|
|
520
|
+
_entry.domains = _entry.domains || [];
|
|
521
|
+
if (!_entry.domains.includes(event.domain)) _entry.domains.push(event.domain);
|
|
522
|
+
}
|
|
523
|
+
if (event.type === 'agent:usage' || event.type === 'agent:spawn' || event.type === 'agent:complete') {
|
|
524
|
+
_entry.hasAgents = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
fs.writeFileSync(indexFile, JSON.stringify(_idx));
|
|
528
|
+
}
|
|
529
|
+
} catch (_) {}
|
|
530
|
+
// ── Legacy mastermind-sessions.json (kept for backwards compat, read by old clients) ──
|
|
531
|
+
try {
|
|
532
|
+
const sessFile = path.join(dataDir, 'mastermind-sessions.json');
|
|
533
|
+
let sessions = [];
|
|
534
|
+
try { sessions = JSON.parse(fs.readFileSync(sessFile, 'utf8')); } catch (_) {}
|
|
535
|
+
if (event.type === 'session:start' && event.session) {
|
|
536
|
+
if (!sessions.find(s => s.id === event.session)) {
|
|
537
|
+
sessions.unshift({ id: event.session, ts: event.ts, prompt: event.prompt || '',
|
|
538
|
+
status: 'running', org: event.org || '', domains: [], startedAt: event.ts });
|
|
539
|
+
}
|
|
540
|
+
} else if (event.session) {
|
|
541
|
+
const s = sessions.find(s => s.id === event.session);
|
|
542
|
+
if (s) {
|
|
543
|
+
if (event.type === 'session:complete') { s.status = event.status || 'complete'; s.endedAt = event.ts; }
|
|
544
|
+
if (event.type === 'domain:dispatch' && event.domain && !s.domains?.includes(event.domain))
|
|
545
|
+
(s.domains = s.domains || []).push(event.domain);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
fs.writeFileSync(sessFile, JSON.stringify(sessions.slice(0, 500)));
|
|
549
|
+
} catch (_) {}
|
|
550
|
+
// For org:stop events, write a stop marker the boss agent can detect
|
|
551
|
+
if (event.type === 'org:stop' && event.org) {
|
|
552
|
+
try {
|
|
553
|
+
const orgName = String(event.org).trim();
|
|
554
|
+
// Validate before any filesystem use — reject rather than strip
|
|
555
|
+
if (orgName.length > 0 && orgName.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
556
|
+
const stopDir = path.join(root, '.monomind', 'orgs', '.stops');
|
|
557
|
+
fs.mkdirSync(stopDir, { recursive: true });
|
|
558
|
+
fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
|
|
559
|
+
}
|
|
560
|
+
} catch (_) {}
|
|
561
|
+
}
|
|
562
|
+
// Broadcast to all mastermind SSE clients
|
|
563
|
+
const msg = `data: ${JSON.stringify(event)}\n\n`;
|
|
564
|
+
for (const c of mmSseClients) { try { c.write(msg); } catch (_) { mmSseClients.delete(c); } }
|
|
565
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
566
|
+
res.end('{"ok":true}');
|
|
567
|
+
}
|
|
568
|
+
|
|
366
569
|
const server = http.createServer(async (req, res) => {
|
|
367
570
|
const url = req.url.split('?')[0];
|
|
368
571
|
|
|
@@ -508,7 +711,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
508
711
|
.map(f => { try { return { f, mtime: fs.statSync(path.join(projectClaudeDir, f)).mtimeMs }; } catch { return null; } })
|
|
509
712
|
.filter(Boolean)
|
|
510
713
|
.sort((a, b) => b.mtime - a.mtime)
|
|
511
|
-
.slice(0,
|
|
714
|
+
.slice(0, 50);
|
|
512
715
|
} catch {}
|
|
513
716
|
|
|
514
717
|
const sessions = [];
|
|
@@ -3217,6 +3420,9 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
3217
3420
|
const stopTs = lastStop ? (JSON.parse(lastStop).ts || 0) : 0;
|
|
3218
3421
|
running = startTs > stopTs;
|
|
3219
3422
|
}
|
|
3423
|
+
// Also check in-memory activeOrgRuns so the list reflects LIVE immediately after launch
|
|
3424
|
+
const _lOrgName = cfg.name || '';
|
|
3425
|
+
if (!running && _lOrgName && activeOrgRuns.has(_lOrgName)) running = true;
|
|
3220
3426
|
orgs.push({ name: cfg.name, goal: cfg.goal, roles: Array.isArray(cfg.roles) ? cfg.roles : [], topology: cfg.topology, created_at: cfg.created_at, running, status: cfg.status, loop: cfg.loop ? { poll_interval_minutes: cfg.loop.poll_interval_minutes, last_run: cfg.loop.last_run, next_run: cfg.loop.next_run } : undefined });
|
|
3221
3427
|
} catch(_) {}
|
|
3222
3428
|
}
|
|
@@ -3313,9 +3519,22 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
3313
3519
|
const routinesData = readJsonSafe(path.join(orgsDir, `${orgName}-routines.json`)) || { routines: [] };
|
|
3314
3520
|
const approvalsData = readJsonSafe(path.join(orgsDir, `${orgName}-approvals.json`)) || { approvals: [] };
|
|
3315
3521
|
|
|
3316
|
-
// Check running status
|
|
3522
|
+
// Check running status: stop file absence AND (in-memory activeOrgRuns OR state-file agents OR active loop file)
|
|
3317
3523
|
const stopFile = path.join(orgsDir, '.stops', `${orgName}.stop`);
|
|
3318
|
-
const
|
|
3524
|
+
const _loopsDir = path.join(d, '.monomind', 'loops');
|
|
3525
|
+
const _loopRunning = (() => {
|
|
3526
|
+
try {
|
|
3527
|
+
if (!fs.existsSync(_loopsDir)) return false;
|
|
3528
|
+
return fs.readdirSync(_loopsDir).some(f => {
|
|
3529
|
+
if (!f.endsWith('.json') || f.endsWith('.stop')) return false;
|
|
3530
|
+
try {
|
|
3531
|
+
const lp = JSON.parse(fs.readFileSync(path.join(_loopsDir, f), 'utf8'));
|
|
3532
|
+
return lp.status === 'running' && lp.command && lp.command.includes('runorg') && (lp.prompt || '').includes(orgName);
|
|
3533
|
+
} catch { return false; }
|
|
3534
|
+
});
|
|
3535
|
+
} catch { return false; }
|
|
3536
|
+
})();
|
|
3537
|
+
const running = !fs.existsSync(stopFile) && (activeOrgRuns.has(orgName) || Object.values(state.agents || {}).some(a => a.status === 'running') || _loopRunning);
|
|
3319
3538
|
|
|
3320
3539
|
// Read real tasks from the task store and group by status column
|
|
3321
3540
|
const taskStoreData = readJsonSafe(path.join(d, '.monomind', 'tasks', 'store.json'));
|
|
@@ -4074,13 +4293,45 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4074
4293
|
total_cost_usd: s.total_cost_usd || 0,
|
|
4075
4294
|
}));
|
|
4076
4295
|
} catch(_) {}
|
|
4077
|
-
//
|
|
4078
|
-
|
|
4296
|
+
// Scan org run jsonl files for agent:usage events (fallback when state.json has no token data)
|
|
4297
|
+
const _hasTokenData = agents.some(a => a.tokens_in > 0 || a.tokens_out > 0 || a.total_cost_usd > 0);
|
|
4298
|
+
if (!_hasTokenData) {
|
|
4079
4299
|
try {
|
|
4080
|
-
const
|
|
4081
|
-
|
|
4300
|
+
const _runsDir = path.join(base, orgName, 'runs');
|
|
4301
|
+
if (fs.existsSync(_runsDir)) {
|
|
4302
|
+
const _usageByRole = {};
|
|
4303
|
+
for (const f of fs.readdirSync(_runsDir)) {
|
|
4304
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
4305
|
+
const lines = fs.readFileSync(path.join(_runsDir, f), 'utf8').split('\n').filter(Boolean);
|
|
4306
|
+
for (const l of lines) {
|
|
4307
|
+
try {
|
|
4308
|
+
const ev = JSON.parse(l);
|
|
4309
|
+
if (ev.type === 'agent:usage' && ev.role) {
|
|
4310
|
+
const role = String(ev.role).trim();
|
|
4311
|
+
if (!_usageByRole[role]) _usageByRole[role] = { tokens_in: 0, tokens_out: 0, total_cost_usd: 0 };
|
|
4312
|
+
_usageByRole[role].tokens_in += Number(ev.tokens_in) || 0;
|
|
4313
|
+
_usageByRole[role].tokens_out += Number(ev.tokens_out) || 0;
|
|
4314
|
+
_usageByRole[role].total_cost_usd += Number(ev.cost_usd) || 0;
|
|
4315
|
+
}
|
|
4316
|
+
} catch(_) {}
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
if (Object.keys(_usageByRole).length > 0) {
|
|
4320
|
+
// Merge usage into agents list; preserve role titles
|
|
4321
|
+
agents = agents.map(a => {
|
|
4322
|
+
const u = _usageByRole[a.id] || {};
|
|
4323
|
+
return { ...a, tokens_in: u.tokens_in || 0, tokens_out: u.tokens_out || 0, total_cost_usd: u.total_cost_usd || 0 };
|
|
4324
|
+
});
|
|
4325
|
+
// Add any roles that appeared in events but aren't in config
|
|
4326
|
+
for (const [role, u] of Object.entries(_usageByRole)) {
|
|
4327
|
+
if (!agents.find(a => a.id === role)) agents.push({ id: role, title: role, ...u });
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4082
4331
|
} catch(_) {}
|
|
4083
4332
|
}
|
|
4333
|
+
// Do NOT fall back to zero-value role stubs — empty agents array is the honest signal
|
|
4334
|
+
// that no usage has been tracked yet; the UI shows "No cost data" rather than $0.0000 rows.
|
|
4084
4335
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4085
4336
|
res.end(JSON.stringify({ ...budgetData, agents }));
|
|
4086
4337
|
} catch(_) { res.writeHead(500); res.end('{"org_budget":{},"agent_budgets":{},"agents":[]}'); }
|
|
@@ -4099,11 +4350,20 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4099
4350
|
try {
|
|
4100
4351
|
const lines = fs.readFileSync(threadsFile, 'utf8').split('\n').filter(l => l.trim());
|
|
4101
4352
|
threads = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
|
|
4353
|
+
// Group 'message' entries (from org:comms) by run_id into synthetic thread objects
|
|
4354
|
+
const msgsByRun = {};
|
|
4355
|
+
threads.filter(t => t.type === 'message').forEach(m => {
|
|
4356
|
+
const rid = m.run_id || 'unknown';
|
|
4357
|
+
if (!msgsByRun[rid]) msgsByRun[rid] = { id: `thread-${rid}`, type: 'thread', subject: `Run ${rid}`, run_id: rid, createdAt: m.ts, messages: [] };
|
|
4358
|
+
msgsByRun[rid].messages.push({ from: m.from, to: m.to, msg: m.msg, ts: m.ts });
|
|
4359
|
+
});
|
|
4360
|
+
const syntheticThreads = Object.values(msgsByRun).map(t => ({ ...t, messageCount: t.messages.length, author: t.messages[0]?.from || null }));
|
|
4102
4361
|
threads = threads.filter(t => t.type === 'thread' || !t.type).map(t => ({
|
|
4103
4362
|
...t,
|
|
4104
4363
|
author: t.author || t.authorName || t.createdBy || t.authorId || null,
|
|
4105
4364
|
messageCount: t.messageCount != null ? t.messageCount : (Array.isArray(t.messages) ? t.messages.length : (typeof t.messages === 'number' ? t.messages : null)),
|
|
4106
4365
|
}));
|
|
4366
|
+
threads = [...threads, ...syntheticThreads];
|
|
4107
4367
|
} catch(_) {}
|
|
4108
4368
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4109
4369
|
res.end(JSON.stringify({ threads }));
|
|
@@ -4155,15 +4415,48 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4155
4415
|
return;
|
|
4156
4416
|
}
|
|
4157
4417
|
|
|
4158
|
-
// GET /api/org/:name/routines — read org routines
|
|
4418
|
+
// GET /api/org/:name/routines — read org routines (falls back to synthesizing from org config's loop object)
|
|
4159
4419
|
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/routines$/i)) {
|
|
4160
4420
|
try {
|
|
4161
4421
|
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
4162
4422
|
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
|
|
4163
4423
|
const _routinesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
4164
|
-
const
|
|
4424
|
+
const _routinesBase = path.resolve(_routinesQs.get('dir') || projectDir || process.cwd());
|
|
4425
|
+
const routinesFile = path.join(_routinesBase, '.monomind', 'orgs', `${orgName}-routines.json`);
|
|
4165
4426
|
let data = { routines: [] };
|
|
4166
4427
|
try { data = JSON.parse(fs.readFileSync(routinesFile, 'utf8')); } catch(_) {}
|
|
4428
|
+
// Synthesize routines from org config's loop/schedule settings when no explicit routines are defined
|
|
4429
|
+
if (!data.routines || !data.routines.length) {
|
|
4430
|
+
try {
|
|
4431
|
+
const orgCfg = JSON.parse(fs.readFileSync(path.join(_routinesBase, '.monomind', 'orgs', `${orgName}.json`), 'utf8'));
|
|
4432
|
+
const loop = orgCfg.loop;
|
|
4433
|
+
if (loop && (loop.poll_interval_minutes || loop.interval_minutes)) {
|
|
4434
|
+
const intervalMin = loop.poll_interval_minutes || loop.interval_minutes;
|
|
4435
|
+
data.routines = [{
|
|
4436
|
+
name: `${orgName}-cycle`,
|
|
4437
|
+
description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org iteration cycle',
|
|
4438
|
+
schedule: `every ${intervalMin}m`,
|
|
4439
|
+
cron: null,
|
|
4440
|
+
enabled: orgCfg.status === 'active',
|
|
4441
|
+
status: orgCfg.status || 'stopped',
|
|
4442
|
+
prompt_file: loop.run_prompt_file || null,
|
|
4443
|
+
source: 'loop-config',
|
|
4444
|
+
lastRun: null,
|
|
4445
|
+
}];
|
|
4446
|
+
} else if (orgCfg.schedule) {
|
|
4447
|
+
data.routines = [{
|
|
4448
|
+
name: `${orgName}-schedule`,
|
|
4449
|
+
description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org scheduled run',
|
|
4450
|
+
schedule: String(orgCfg.schedule),
|
|
4451
|
+
cron: null,
|
|
4452
|
+
enabled: orgCfg.status === 'active',
|
|
4453
|
+
status: orgCfg.status || 'stopped',
|
|
4454
|
+
source: 'schedule-config',
|
|
4455
|
+
lastRun: null,
|
|
4456
|
+
}];
|
|
4457
|
+
}
|
|
4458
|
+
} catch(_) {}
|
|
4459
|
+
}
|
|
4167
4460
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
4168
4461
|
res.end(JSON.stringify({ routines: data.routines || [] }));
|
|
4169
4462
|
} catch(_) { res.writeHead(500); res.end('{"routines":[]}'); }
|
|
@@ -4404,7 +4697,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4404
4697
|
const allLines = raw.split('\n').filter(Boolean);
|
|
4405
4698
|
const eventCount = allLines.length;
|
|
4406
4699
|
const parse = l => { try { return JSON.parse(l); } catch { return null; } };
|
|
4407
|
-
const headEvents = allLines.slice(0,
|
|
4700
|
+
const headEvents = allLines.slice(0, 10).map(parse).filter(Boolean);
|
|
4408
4701
|
const tailEvents = allLines.slice(-5).map(parse).filter(Boolean);
|
|
4409
4702
|
const first = headEvents.find(e => e.type === 'run:start') || headEvents[0];
|
|
4410
4703
|
const last = tailEvents.slice().reverse().find(e => e.type === 'run:complete' || e.type === 'org:complete');
|
|
@@ -4412,9 +4705,12 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4412
4705
|
const lastEvent = tailEvents[tailEvents.length - 1] || headEvents[headEvents.length - 1];
|
|
4413
4706
|
const ageMs = lastEvent?.ts ? Date.now() - lastEvent.ts : Infinity;
|
|
4414
4707
|
const isStale = !last && ageMs > 30 * 60 * 1000;
|
|
4708
|
+
// Derive a human-readable goal from the first boss directive when run:start lacks one
|
|
4709
|
+
const firstBossComms = headEvents.find(e => e.type === 'org:comms' && (e.from === 'boss' || e.role === 'boss') && e.msg);
|
|
4710
|
+
const derivedGoal = first?.goal || firstBossComms?.msg?.slice(0, 80) || '';
|
|
4415
4711
|
runs.push({ runId: f.replace('.jsonl', ''), startedAt: first?.ts || 0, endedAt: last?.ts || 0,
|
|
4416
4712
|
status: last ? 'complete' : isStale ? 'stale' : 'running',
|
|
4417
|
-
eventCount, cycleCount: cycles, goal:
|
|
4713
|
+
eventCount, cycleCount: cycles, goal: derivedGoal, bossRole: first?.bossRole || '' });
|
|
4418
4714
|
} catch (_) {}
|
|
4419
4715
|
}
|
|
4420
4716
|
}
|
|
@@ -4446,114 +4742,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4446
4742
|
// ------------------------------------------------- Mastermind event system
|
|
4447
4743
|
// POST /api/mastermind/event — ingest event from mastermind skill
|
|
4448
4744
|
if (req.method === 'POST' && url === '/api/mastermind/event') {
|
|
4449
|
-
|
|
4450
|
-
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
4451
|
-
let event = {};
|
|
4452
|
-
try { event = JSON.parse(body); } catch (_) {}
|
|
4453
|
-
event.ts = event.ts || Date.now();
|
|
4454
|
-
// Use project path from event if provided (multi-project support).
|
|
4455
|
-
// Security: path.isAbsolute() alone is insufficient — an attacker can
|
|
4456
|
-
// supply event.project="/etc" and cause writes to system directories.
|
|
4457
|
-
// Only accept paths that resolve to an existing directory AND are not
|
|
4458
|
-
// the filesystem root (/), AND are not obviously system paths.
|
|
4459
|
-
// Cap to 4096 chars to prevent OOM from huge path strings.
|
|
4460
|
-
const _rawProject = event.project;
|
|
4461
|
-
let eventProject = null;
|
|
4462
|
-
if (typeof _rawProject === 'string' && _rawProject.length > 0 && _rawProject.length <= 4096
|
|
4463
|
-
&& path.isAbsolute(_rawProject)) {
|
|
4464
|
-
// Reject filesystem root and common system directories
|
|
4465
|
-
const _norm = path.resolve(_rawProject);
|
|
4466
|
-
const _systemPaths = ['/', '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/dev', '/sys', '/proc', '/tmp'];
|
|
4467
|
-
if (!_systemPaths.includes(_norm) && !_systemPaths.some(p => _norm.startsWith(p + '/'))) {
|
|
4468
|
-
eventProject = _norm;
|
|
4469
|
-
}
|
|
4470
|
-
}
|
|
4471
|
-
const root = eventProject || projectDir || process.cwd();
|
|
4472
|
-
const dataDir = path.join(root, 'data');
|
|
4473
|
-
try { fs.mkdirSync(dataDir, { recursive: true }); } catch (_) {}
|
|
4474
|
-
// Track known project dirs for aggregated session listing
|
|
4475
|
-
if (eventProject) {
|
|
4476
|
-
const knownFile = path.join(projectDir || process.cwd(), 'data', 'known-projects.json');
|
|
4477
|
-
try {
|
|
4478
|
-
let known = [];
|
|
4479
|
-
try { known = JSON.parse(fs.readFileSync(knownFile, 'utf8')); } catch (_) {}
|
|
4480
|
-
if (!known.includes(eventProject)) { known.push(eventProject); fs.writeFileSync(knownFile, JSON.stringify(known)); }
|
|
4481
|
-
} catch (_) {}
|
|
4482
|
-
}
|
|
4483
|
-
try { fs.appendFileSync(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(event) + '\n'); } catch (_) {}
|
|
4484
|
-
// Track active runs and route org events to run files
|
|
4485
|
-
if (event.org) {
|
|
4486
|
-
const _orgKey = String(event.org).trim();
|
|
4487
|
-
// Any event with both org+runId updates the active run map (run:start written directly to file so org:start is first via curl)
|
|
4488
|
-
if (event.runId) activeOrgRuns.set(_orgKey, String(event.runId).trim());
|
|
4489
|
-
else if (activeOrgRuns.has(_orgKey)) event.runId = activeOrgRuns.get(_orgKey);
|
|
4490
|
-
if (event.type === 'run:complete' || event.type === 'org:complete') activeOrgRuns.delete(_orgKey);
|
|
4491
|
-
}
|
|
4492
|
-
// Persist to git-safe run file (survives branch switches + shared across worktrees)
|
|
4493
|
-
if (event.org && event.runId) {
|
|
4494
|
-
try {
|
|
4495
|
-
const _orn = String(event.org).trim();
|
|
4496
|
-
const _rid = String(event.runId).trim();
|
|
4497
|
-
if (_orn.length > 0 && _orn.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(_orn)
|
|
4498
|
-
&& _rid.length > 0 && _rid.length <= 80 && /^[a-z0-9][a-z0-9_-]*$/i.test(_rid)) {
|
|
4499
|
-
const _monoDir = _getGitMonomindDir(root) || path.join(root, '.monomind');
|
|
4500
|
-
const _runDir = path.join(_monoDir, 'orgs', _orn, 'runs');
|
|
4501
|
-
fs.mkdirSync(_runDir, { recursive: true });
|
|
4502
|
-
fs.appendFileSync(path.join(_runDir, `${_rid}.jsonl`), JSON.stringify(event) + '\n');
|
|
4503
|
-
}
|
|
4504
|
-
} catch (_) {}
|
|
4505
|
-
}
|
|
4506
|
-
// Persist session
|
|
4507
|
-
try {
|
|
4508
|
-
const sessFile = path.join(dataDir, 'mastermind-sessions.json');
|
|
4509
|
-
let sessions = [];
|
|
4510
|
-
try { sessions = JSON.parse(fs.readFileSync(sessFile, 'utf8')); } catch (_) {}
|
|
4511
|
-
if (event.type === 'session:start') {
|
|
4512
|
-
sessions.unshift({ id: event.session, ts: event.ts, prompt: event.prompt || '',
|
|
4513
|
-
status: 'running', domains: [], events: [event], project: root });
|
|
4514
|
-
} else {
|
|
4515
|
-
const s = sessions.find(s => s.id === event.session);
|
|
4516
|
-
if (s) {
|
|
4517
|
-
(s.events = s.events || []).push(event);
|
|
4518
|
-
if (event.type === 'domain:dispatch' && event.domain && !s.domains.includes(event.domain))
|
|
4519
|
-
s.domains.push(event.domain);
|
|
4520
|
-
if (event.type === 'session:complete') { s.status = event.status || 'complete'; s.endTs = event.ts; }
|
|
4521
|
-
}
|
|
4522
|
-
}
|
|
4523
|
-
fs.writeFileSync(sessFile, JSON.stringify(sessions.slice(0, 50), null, 2));
|
|
4524
|
-
// Also write individual session file for direct traceability.
|
|
4525
|
-
// Security: validate event.session before using it as a filename to
|
|
4526
|
-
// prevent path traversal (e.g. "../../../etc/cron.d/payload").
|
|
4527
|
-
const sessionObj = sessions.find(s => s.id === event.session);
|
|
4528
|
-
if (sessionObj) {
|
|
4529
|
-
const sessDir = path.join(dataDir, 'sessions');
|
|
4530
|
-
try { fs.mkdirSync(sessDir, { recursive: true }); } catch (_) {}
|
|
4531
|
-
try {
|
|
4532
|
-
const _sid = String(event.session || '').trim();
|
|
4533
|
-
if (_sid.length > 0 && _sid.length <= 128 && /^[a-zA-Z0-9_.-]+$/.test(_sid)) {
|
|
4534
|
-
fs.writeFileSync(path.join(sessDir, `${_sid}.json`), JSON.stringify(sessionObj, null, 2));
|
|
4535
|
-
}
|
|
4536
|
-
} catch (_) {}
|
|
4537
|
-
}
|
|
4538
|
-
} catch (_) {}
|
|
4539
|
-
// For org:stop events, write a stop marker the boss agent can detect
|
|
4540
|
-
if (event.type === 'org:stop' && event.org) {
|
|
4541
|
-
try {
|
|
4542
|
-
const orgName = String(event.org).trim();
|
|
4543
|
-
// Validate before any filesystem use — reject rather than strip
|
|
4544
|
-
if (orgName.length > 0 && orgName.length <= 64 && /^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
4545
|
-
const stopDir = path.join(root, '.monomind', 'orgs', '.stops');
|
|
4546
|
-
fs.mkdirSync(stopDir, { recursive: true });
|
|
4547
|
-
fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
|
|
4548
|
-
}
|
|
4549
|
-
} catch (_) {}
|
|
4550
|
-
}
|
|
4551
|
-
// Broadcast to all mastermind SSE clients
|
|
4552
|
-
const msg = `data: ${JSON.stringify(event)}\n\n`;
|
|
4553
|
-
for (const c of mmSseClients) { try { c.write(msg); } catch (_) { mmSseClients.delete(c); } }
|
|
4554
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
4555
|
-
res.end('{"ok":true}');
|
|
4556
|
-
return;
|
|
4745
|
+
return handleMastermindEvent(req, res);
|
|
4557
4746
|
}
|
|
4558
4747
|
|
|
4559
4748
|
// GET /api/mastermind-stream — SSE for real-time events
|
|
@@ -4583,6 +4772,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4583
4772
|
try {
|
|
4584
4773
|
const qp = new URL('http://x' + req.url).searchParams;
|
|
4585
4774
|
const filterProject = qp.get('project');
|
|
4775
|
+
const limitParam = Math.min(parseInt(qp.get('limit') || '200', 10) || 200, 500);
|
|
4586
4776
|
const serverRoot = projectDir || process.cwd();
|
|
4587
4777
|
// Collect all project dirs to aggregate
|
|
4588
4778
|
const projectDirs = new Set([serverRoot]);
|
|
@@ -4590,23 +4780,44 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4590
4780
|
const known = JSON.parse(fs.readFileSync(path.join(serverRoot, 'data', 'known-projects.json'), 'utf8'));
|
|
4591
4781
|
known.forEach(p => projectDirs.add(p));
|
|
4592
4782
|
} catch (_) {}
|
|
4593
|
-
// Load and merge sessions from all dirs
|
|
4594
4783
|
let allSessions = [];
|
|
4595
4784
|
for (const pd of projectDirs) {
|
|
4596
4785
|
if (filterProject && pd !== filterProject) continue;
|
|
4597
|
-
const
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4786
|
+
const sessDir = path.join(pd, 'data', 'sessions');
|
|
4787
|
+
const indexFile = path.join(sessDir, '_index.json');
|
|
4788
|
+
// ── New format: per-session JSONL + _index.json ──
|
|
4789
|
+
if (fs.existsSync(indexFile)) {
|
|
4790
|
+
try {
|
|
4791
|
+
const idx = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
4792
|
+
const top = idx.slice(0, limitParam);
|
|
4793
|
+
for (const entry of top) {
|
|
4794
|
+
const _sid = String(entry.id || '').trim();
|
|
4795
|
+
if (!_sid || !/^[a-zA-Z0-9_.-]+$/.test(_sid)) continue;
|
|
4796
|
+
let events = [];
|
|
4797
|
+
try {
|
|
4798
|
+
const jl = fs.readFileSync(path.join(sessDir, `${_sid}.jsonl`), 'utf8');
|
|
4799
|
+
events = jl.trim().split('\n').filter(Boolean)
|
|
4800
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
4801
|
+
.filter(Boolean);
|
|
4802
|
+
} catch(_) {}
|
|
4803
|
+
allSessions.push({ ...entry, events, project: pd });
|
|
4804
|
+
}
|
|
4805
|
+
} catch(_) {}
|
|
4806
|
+
} else {
|
|
4807
|
+
// ── Legacy fallback: mastermind-sessions.json ──
|
|
4808
|
+
const f = path.join(pd, 'data', 'mastermind-sessions.json');
|
|
4809
|
+
if (fs.existsSync(f)) {
|
|
4810
|
+
try {
|
|
4811
|
+
const s = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
4812
|
+
s.forEach(sess => { if (!sess.project) sess.project = pd; });
|
|
4813
|
+
allSessions = allSessions.concat(s);
|
|
4814
|
+
} catch (_) {}
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4605
4817
|
}
|
|
4606
|
-
|
|
4607
|
-
allSessions.sort((a,b) => (b.ts||0)-(a.ts||0));
|
|
4818
|
+
allSessions.sort((a,b) => (b.ts||b.startedAt||0)-(a.ts||a.startedAt||0));
|
|
4608
4819
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
4609
|
-
res.end(JSON.stringify(allSessions.slice(0,
|
|
4820
|
+
res.end(JSON.stringify(allSessions.slice(0, limitParam)));
|
|
4610
4821
|
} catch (_) { res.writeHead(200); res.end('[]'); }
|
|
4611
4822
|
return;
|
|
4612
4823
|
}
|
|
@@ -4728,6 +4939,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4728
4939
|
res.end(JSON.stringify({
|
|
4729
4940
|
ts: Date.now(),
|
|
4730
4941
|
uptime: process.uptime(),
|
|
4942
|
+
dir: root,
|
|
4731
4943
|
sseClients: mmSseClients.size,
|
|
4732
4944
|
activeOrgs: Object.keys(orgRuns).length,
|
|
4733
4945
|
orgRuns,
|
|
@@ -4744,7 +4956,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4744
4956
|
if (req.method === 'GET' && /^\/api\/orgs\/[^/]+\/runs\/current$/.test(url)) {
|
|
4745
4957
|
try {
|
|
4746
4958
|
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
4747
|
-
const
|
|
4959
|
+
const _curQs = new URL(req.url, 'http://localhost').searchParams;
|
|
4960
|
+
const root = path.resolve(_curQs.get('dir') || projectDir || process.cwd());
|
|
4748
4961
|
// Validate orgName
|
|
4749
4962
|
if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
4750
4963
|
res.writeHead(400); res.end('{"error":"invalid org name"}'); return;
|
|
@@ -4806,6 +5019,63 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
|
|
|
4806
5019
|
const boundPort = await bindServer(server, port);
|
|
4807
5020
|
const url = `http://localhost:${boundPort}`;
|
|
4808
5021
|
|
|
5022
|
+
// ── One-time migration: mastermind-sessions.json → per-session JSONL ─────
|
|
5023
|
+
// Runs once on startup. Existing sessions in the old monolithic format are
|
|
5024
|
+
// split into individual JSONL files + _index.json for O(1) event writes.
|
|
5025
|
+
try {
|
|
5026
|
+
const _migDataDir = path.join(projectDir || process.cwd(), 'data');
|
|
5027
|
+
const _migOldFile = path.join(_migDataDir, 'mastermind-sessions.json');
|
|
5028
|
+
const _migSessDir = path.join(_migDataDir, 'sessions');
|
|
5029
|
+
const _migIndexFile = path.join(_migSessDir, '_index.json');
|
|
5030
|
+
if (fs.existsSync(_migOldFile) && !fs.existsSync(_migIndexFile)) {
|
|
5031
|
+
try {
|
|
5032
|
+
const _migOld = JSON.parse(fs.readFileSync(_migOldFile, 'utf8'));
|
|
5033
|
+
fs.mkdirSync(_migSessDir, { recursive: true });
|
|
5034
|
+
const _migIndex = [];
|
|
5035
|
+
for (const sess of (_migOld || [])) {
|
|
5036
|
+
const _msid = String(sess.id || '').trim();
|
|
5037
|
+
if (!_msid || !/^[a-zA-Z0-9_.-]+$/.test(_msid)) continue;
|
|
5038
|
+
// Write per-session JSONL
|
|
5039
|
+
const _mEvts = (sess.events || []);
|
|
5040
|
+
const _mLines = _mEvts.map(e => JSON.stringify(e)).join('\n');
|
|
5041
|
+
fs.writeFileSync(path.join(_migSessDir, `${_msid}.jsonl`), _mLines + (_mLines ? '\n' : ''));
|
|
5042
|
+
_migIndex.push({ id: _msid, ts: sess.ts, prompt: sess.prompt || '',
|
|
5043
|
+
status: sess.status || 'complete', org: sess.org || '',
|
|
5044
|
+
startedAt: sess.ts || sess.startedAt, endedAt: sess.endTs || sess.endedAt,
|
|
5045
|
+
domains: sess.domains || [] });
|
|
5046
|
+
}
|
|
5047
|
+
fs.writeFileSync(_migIndexFile, JSON.stringify(_migIndex));
|
|
5048
|
+
console.log('[server] migrated ' + _migIndex.length + ' sessions to per-session JSONL format');
|
|
5049
|
+
} catch(_me) { console.warn('[server] session migration failed:', _me.message); }
|
|
5050
|
+
}
|
|
5051
|
+
} catch (_) {}
|
|
5052
|
+
|
|
5053
|
+
// Rebuild activeOrgRuns from disk so event enrichment (runId injection) still works
|
|
5054
|
+
// after a server restart. Without this, org events emitted mid-run that lack runId
|
|
5055
|
+
// are broadcast without it and _odtHandleLiveEvent drops them.
|
|
5056
|
+
try {
|
|
5057
|
+
const _rbOrgsDir = path.join(projectDir || process.cwd(), '.monomind', 'orgs');
|
|
5058
|
+
if (fs.existsSync(_rbOrgsDir)) {
|
|
5059
|
+
for (const _rbOrg of fs.readdirSync(_rbOrgsDir)) {
|
|
5060
|
+
if (!_rbOrg || _rbOrg.startsWith('.') || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rbOrg)) continue;
|
|
5061
|
+
const _rbRunsDir = path.join(_rbOrgsDir, _rbOrg, 'runs');
|
|
5062
|
+
if (!fs.existsSync(_rbRunsDir)) continue;
|
|
5063
|
+
const _rbFiles = fs.readdirSync(_rbRunsDir)
|
|
5064
|
+
.filter(f => f.endsWith('.jsonl') && !f.endsWith('.convs.jsonl'))
|
|
5065
|
+
.sort().reverse();
|
|
5066
|
+
for (const _rbF of _rbFiles.slice(0, 5)) {
|
|
5067
|
+
try {
|
|
5068
|
+
const _rbId = _rbF.replace('.jsonl', '');
|
|
5069
|
+
const _rbContent = fs.readFileSync(path.join(_rbRunsDir, _rbF), 'utf8');
|
|
5070
|
+
const _rbLast = _rbContent.trim().split('\n').filter(Boolean).slice(-10);
|
|
5071
|
+
const _rbDone = _rbLast.some(l => { try { const e = JSON.parse(l); return e.type === 'run:complete' || e.type === 'org:complete'; } catch { return false; } });
|
|
5072
|
+
if (!_rbDone) { activeOrgRuns.set(_rbOrg, _rbId); break; }
|
|
5073
|
+
} catch (_) {}
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
} catch (_) {}
|
|
5078
|
+
|
|
4809
5079
|
// ---------------------------------------------------------------- Watchers
|
|
4810
5080
|
let debounceTimer = null;
|
|
4811
5081
|
let pendingSections = new Set();
|