@monoes/monomindcli 1.13.0 → 1.14.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.
Files changed (39) hide show
  1. package/.claude/agents/generated/churn-analyst.md +53 -0
  2. package/.claude/agents/generated/code-reviewer.md +55 -0
  3. package/.claude/agents/generated/code-validator.md +57 -0
  4. package/.claude/agents/generated/complexity-scanner.md +56 -0
  5. package/.claude/agents/generated/devbot-orchestrator.md +58 -0
  6. package/.claude/agents/generated/devbot-planner.md +63 -0
  7. package/.claude/agents/generated/impact-assessor.md +54 -0
  8. package/.claude/commands/mastermind/master.md +88 -24
  9. package/.claude/helpers/control-start.cjs +60 -1
  10. package/.claude/helpers/event-logger.cjs +43 -2
  11. package/.claude/helpers/handlers/capture-handler.cjs +336 -0
  12. package/.claude/helpers/handlers/route-handler.cjs +11 -11
  13. package/.claude/helpers/hook-handler.cjs +17 -1
  14. package/.claude/helpers/session.cjs +20 -2
  15. package/.claude/skills/mastermind/createorg.md +227 -16
  16. package/.claude/skills/mastermind/idea.md +15 -3
  17. package/.claude/skills/mastermind/runorg.md +2 -1
  18. package/dist/src/commands/index.js +2 -0
  19. package/dist/src/commands/org.d.ts +4 -0
  20. package/dist/src/commands/org.d.ts.map +1 -0
  21. package/dist/src/commands/org.js +93 -0
  22. package/dist/src/commands/org.js.map +1 -0
  23. package/dist/src/mcp-tools/memory-tools.js +6 -6
  24. package/dist/src/mcp-tools/memory-tools.js.map +1 -1
  25. package/dist/src/mcp-tools/session-tools.d.ts.map +1 -1
  26. package/dist/src/mcp-tools/session-tools.js +9 -10
  27. package/dist/src/mcp-tools/session-tools.js.map +1 -1
  28. package/dist/src/mcp-tools/task-tools.d.ts.map +1 -1
  29. package/dist/src/mcp-tools/task-tools.js +7 -8
  30. package/dist/src/mcp-tools/task-tools.js.map +1 -1
  31. package/dist/src/mcp-tools/types.d.ts +1 -0
  32. package/dist/src/mcp-tools/types.d.ts.map +1 -1
  33. package/dist/src/mcp-tools/types.js +49 -0
  34. package/dist/src/mcp-tools/types.js.map +1 -1
  35. package/dist/src/ui/dashboard.html +1639 -249
  36. package/dist/src/ui/orgs.html +1 -0
  37. package/dist/src/ui/server.mjs +389 -132
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +1 -1
@@ -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, 15);
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,9 @@ 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 from stop file absence + state
3522
+ // Check running status: stop file absence AND (in-memory activeOrgRuns OR state-file agents)
3317
3523
  const stopFile = path.join(orgsDir, '.stops', `${orgName}.stop`);
3318
- const running = !fs.existsSync(stopFile) && Object.values(state.agents || {}).some(a => a.status === 'running');
3524
+ const running = !fs.existsSync(stopFile) && (activeOrgRuns.has(orgName) || Object.values(state.agents || {}).some(a => a.status === 'running'));
3319
3525
 
3320
3526
  // Read real tasks from the task store and group by status column
3321
3527
  const taskStoreData = readJsonSafe(path.join(d, '.monomind', 'tasks', 'store.json'));
@@ -4074,13 +4280,45 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4074
4280
  total_cost_usd: s.total_cost_usd || 0,
4075
4281
  }));
4076
4282
  } catch(_) {}
4077
- // Also include roles from org config if state is empty
4078
- if (!agents.length) {
4283
+ // Scan org run jsonl files for agent:usage events (fallback when state.json has no token data)
4284
+ const _hasTokenData = agents.some(a => a.tokens_in > 0 || a.tokens_out > 0 || a.total_cost_usd > 0);
4285
+ if (!_hasTokenData) {
4079
4286
  try {
4080
- const org = JSON.parse(fs.readFileSync(path.join(base, `${orgName}.json`), 'utf8'));
4081
- agents = (org.roles || []).map(r => ({ id: r.id, title: r.title, tokens_in: 0, tokens_out: 0, total_cost_usd: 0 }));
4287
+ const _runsDir = path.join(base, orgName, 'runs');
4288
+ if (fs.existsSync(_runsDir)) {
4289
+ const _usageByRole = {};
4290
+ for (const f of fs.readdirSync(_runsDir)) {
4291
+ if (!f.endsWith('.jsonl')) continue;
4292
+ const lines = fs.readFileSync(path.join(_runsDir, f), 'utf8').split('\n').filter(Boolean);
4293
+ for (const l of lines) {
4294
+ try {
4295
+ const ev = JSON.parse(l);
4296
+ if (ev.type === 'agent:usage' && ev.role) {
4297
+ const role = String(ev.role).trim();
4298
+ if (!_usageByRole[role]) _usageByRole[role] = { tokens_in: 0, tokens_out: 0, total_cost_usd: 0 };
4299
+ _usageByRole[role].tokens_in += Number(ev.tokens_in) || 0;
4300
+ _usageByRole[role].tokens_out += Number(ev.tokens_out) || 0;
4301
+ _usageByRole[role].total_cost_usd += Number(ev.cost_usd) || 0;
4302
+ }
4303
+ } catch(_) {}
4304
+ }
4305
+ }
4306
+ if (Object.keys(_usageByRole).length > 0) {
4307
+ // Merge usage into agents list; preserve role titles
4308
+ agents = agents.map(a => {
4309
+ const u = _usageByRole[a.id] || {};
4310
+ return { ...a, tokens_in: u.tokens_in || 0, tokens_out: u.tokens_out || 0, total_cost_usd: u.total_cost_usd || 0 };
4311
+ });
4312
+ // Add any roles that appeared in events but aren't in config
4313
+ for (const [role, u] of Object.entries(_usageByRole)) {
4314
+ if (!agents.find(a => a.id === role)) agents.push({ id: role, title: role, ...u });
4315
+ }
4316
+ }
4317
+ }
4082
4318
  } catch(_) {}
4083
4319
  }
4320
+ // Do NOT fall back to zero-value role stubs — empty agents array is the honest signal
4321
+ // that no usage has been tracked yet; the UI shows "No cost data" rather than $0.0000 rows.
4084
4322
  res.writeHead(200, { 'Content-Type': 'application/json' });
4085
4323
  res.end(JSON.stringify({ ...budgetData, agents }));
4086
4324
  } catch(_) { res.writeHead(500); res.end('{"org_budget":{},"agent_budgets":{},"agents":[]}'); }
@@ -4099,11 +4337,20 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4099
4337
  try {
4100
4338
  const lines = fs.readFileSync(threadsFile, 'utf8').split('\n').filter(l => l.trim());
4101
4339
  threads = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
4340
+ // Group 'message' entries (from org:comms) by run_id into synthetic thread objects
4341
+ const msgsByRun = {};
4342
+ threads.filter(t => t.type === 'message').forEach(m => {
4343
+ const rid = m.run_id || 'unknown';
4344
+ if (!msgsByRun[rid]) msgsByRun[rid] = { id: `thread-${rid}`, type: 'thread', subject: `Run ${rid}`, run_id: rid, createdAt: m.ts, messages: [] };
4345
+ msgsByRun[rid].messages.push({ from: m.from, to: m.to, msg: m.msg, ts: m.ts });
4346
+ });
4347
+ const syntheticThreads = Object.values(msgsByRun).map(t => ({ ...t, messageCount: t.messages.length, author: t.messages[0]?.from || null }));
4102
4348
  threads = threads.filter(t => t.type === 'thread' || !t.type).map(t => ({
4103
4349
  ...t,
4104
4350
  author: t.author || t.authorName || t.createdBy || t.authorId || null,
4105
4351
  messageCount: t.messageCount != null ? t.messageCount : (Array.isArray(t.messages) ? t.messages.length : (typeof t.messages === 'number' ? t.messages : null)),
4106
4352
  }));
4353
+ threads = [...threads, ...syntheticThreads];
4107
4354
  } catch(_) {}
4108
4355
  res.writeHead(200, { 'Content-Type': 'application/json' });
4109
4356
  res.end(JSON.stringify({ threads }));
@@ -4155,15 +4402,48 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4155
4402
  return;
4156
4403
  }
4157
4404
 
4158
- // GET /api/org/:name/routines — read org routines
4405
+ // GET /api/org/:name/routines — read org routines (falls back to synthesizing from org config's loop object)
4159
4406
  if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/routines$/i)) {
4160
4407
  try {
4161
4408
  const orgName = decodeURIComponent(url.split('/')[3]);
4162
4409
  if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return; }
4163
4410
  const _routinesQs = new URL(req.url, 'http://localhost').searchParams;
4164
- const routinesFile = path.join(path.resolve(_routinesQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-routines.json`);
4411
+ const _routinesBase = path.resolve(_routinesQs.get('dir') || projectDir || process.cwd());
4412
+ const routinesFile = path.join(_routinesBase, '.monomind', 'orgs', `${orgName}-routines.json`);
4165
4413
  let data = { routines: [] };
4166
4414
  try { data = JSON.parse(fs.readFileSync(routinesFile, 'utf8')); } catch(_) {}
4415
+ // Synthesize routines from org config's loop/schedule settings when no explicit routines are defined
4416
+ if (!data.routines || !data.routines.length) {
4417
+ try {
4418
+ const orgCfg = JSON.parse(fs.readFileSync(path.join(_routinesBase, '.monomind', 'orgs', `${orgName}.json`), 'utf8'));
4419
+ const loop = orgCfg.loop;
4420
+ if (loop && (loop.poll_interval_minutes || loop.interval_minutes)) {
4421
+ const intervalMin = loop.poll_interval_minutes || loop.interval_minutes;
4422
+ data.routines = [{
4423
+ name: `${orgName}-cycle`,
4424
+ description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org iteration cycle',
4425
+ schedule: `every ${intervalMin}m`,
4426
+ cron: null,
4427
+ enabled: orgCfg.status === 'active',
4428
+ status: orgCfg.status || 'stopped',
4429
+ prompt_file: loop.run_prompt_file || null,
4430
+ source: 'loop-config',
4431
+ lastRun: null,
4432
+ }];
4433
+ } else if (orgCfg.schedule) {
4434
+ data.routines = [{
4435
+ name: `${orgName}-schedule`,
4436
+ description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org scheduled run',
4437
+ schedule: String(orgCfg.schedule),
4438
+ cron: null,
4439
+ enabled: orgCfg.status === 'active',
4440
+ status: orgCfg.status || 'stopped',
4441
+ source: 'schedule-config',
4442
+ lastRun: null,
4443
+ }];
4444
+ }
4445
+ } catch(_) {}
4446
+ }
4167
4447
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4168
4448
  res.end(JSON.stringify({ routines: data.routines || [] }));
4169
4449
  } catch(_) { res.writeHead(500); res.end('{"routines":[]}'); }
@@ -4404,7 +4684,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4404
4684
  const allLines = raw.split('\n').filter(Boolean);
4405
4685
  const eventCount = allLines.length;
4406
4686
  const parse = l => { try { return JSON.parse(l); } catch { return null; } };
4407
- const headEvents = allLines.slice(0, 5).map(parse).filter(Boolean);
4687
+ const headEvents = allLines.slice(0, 10).map(parse).filter(Boolean);
4408
4688
  const tailEvents = allLines.slice(-5).map(parse).filter(Boolean);
4409
4689
  const first = headEvents.find(e => e.type === 'run:start') || headEvents[0];
4410
4690
  const last = tailEvents.slice().reverse().find(e => e.type === 'run:complete' || e.type === 'org:complete');
@@ -4412,9 +4692,12 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4412
4692
  const lastEvent = tailEvents[tailEvents.length - 1] || headEvents[headEvents.length - 1];
4413
4693
  const ageMs = lastEvent?.ts ? Date.now() - lastEvent.ts : Infinity;
4414
4694
  const isStale = !last && ageMs > 30 * 60 * 1000;
4695
+ // Derive a human-readable goal from the first boss directive when run:start lacks one
4696
+ const firstBossComms = headEvents.find(e => e.type === 'org:comms' && (e.from === 'boss' || e.role === 'boss') && e.msg);
4697
+ const derivedGoal = first?.goal || firstBossComms?.msg?.slice(0, 80) || '';
4415
4698
  runs.push({ runId: f.replace('.jsonl', ''), startedAt: first?.ts || 0, endedAt: last?.ts || 0,
4416
4699
  status: last ? 'complete' : isStale ? 'stale' : 'running',
4417
- eventCount, cycleCount: cycles, goal: first?.goal || '', bossRole: first?.bossRole || '' });
4700
+ eventCount, cycleCount: cycles, goal: derivedGoal, bossRole: first?.bossRole || '' });
4418
4701
  } catch (_) {}
4419
4702
  }
4420
4703
  }
@@ -4446,114 +4729,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4446
4729
  // ------------------------------------------------- Mastermind event system
4447
4730
  // POST /api/mastermind/event — ingest event from mastermind skill
4448
4731
  if (req.method === 'POST' && url === '/api/mastermind/event') {
4449
- let body = '';
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;
4732
+ return handleMastermindEvent(req, res);
4557
4733
  }
4558
4734
 
4559
4735
  // GET /api/mastermind-stream — SSE for real-time events
@@ -4583,6 +4759,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4583
4759
  try {
4584
4760
  const qp = new URL('http://x' + req.url).searchParams;
4585
4761
  const filterProject = qp.get('project');
4762
+ const limitParam = Math.min(parseInt(qp.get('limit') || '200', 10) || 200, 500);
4586
4763
  const serverRoot = projectDir || process.cwd();
4587
4764
  // Collect all project dirs to aggregate
4588
4765
  const projectDirs = new Set([serverRoot]);
@@ -4590,23 +4767,44 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4590
4767
  const known = JSON.parse(fs.readFileSync(path.join(serverRoot, 'data', 'known-projects.json'), 'utf8'));
4591
4768
  known.forEach(p => projectDirs.add(p));
4592
4769
  } catch (_) {}
4593
- // Load and merge sessions from all dirs
4594
4770
  let allSessions = [];
4595
4771
  for (const pd of projectDirs) {
4596
4772
  if (filterProject && pd !== filterProject) continue;
4597
- const f = path.join(pd, 'data', 'mastermind-sessions.json');
4598
- if (!fs.existsSync(f)) continue;
4599
- try {
4600
- const s = JSON.parse(fs.readFileSync(f, 'utf8'));
4601
- // Tag each session with its project if not already tagged
4602
- s.forEach(sess => { if (!sess.project) sess.project = pd; });
4603
- allSessions = allSessions.concat(s);
4604
- } catch (_) {}
4773
+ const sessDir = path.join(pd, 'data', 'sessions');
4774
+ const indexFile = path.join(sessDir, '_index.json');
4775
+ // ── New format: per-session JSONL + _index.json ──
4776
+ if (fs.existsSync(indexFile)) {
4777
+ try {
4778
+ const idx = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
4779
+ const top = idx.slice(0, limitParam);
4780
+ for (const entry of top) {
4781
+ const _sid = String(entry.id || '').trim();
4782
+ if (!_sid || !/^[a-zA-Z0-9_.-]+$/.test(_sid)) continue;
4783
+ let events = [];
4784
+ try {
4785
+ const jl = fs.readFileSync(path.join(sessDir, `${_sid}.jsonl`), 'utf8');
4786
+ events = jl.trim().split('\n').filter(Boolean)
4787
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
4788
+ .filter(Boolean);
4789
+ } catch(_) {}
4790
+ allSessions.push({ ...entry, events, project: pd });
4791
+ }
4792
+ } catch(_) {}
4793
+ } else {
4794
+ // ── Legacy fallback: mastermind-sessions.json ──
4795
+ const f = path.join(pd, 'data', 'mastermind-sessions.json');
4796
+ if (fs.existsSync(f)) {
4797
+ try {
4798
+ const s = JSON.parse(fs.readFileSync(f, 'utf8'));
4799
+ s.forEach(sess => { if (!sess.project) sess.project = pd; });
4800
+ allSessions = allSessions.concat(s);
4801
+ } catch (_) {}
4802
+ }
4803
+ }
4605
4804
  }
4606
- // Sort by ts descending, cap at 100
4607
- allSessions.sort((a,b) => (b.ts||0)-(a.ts||0));
4805
+ allSessions.sort((a,b) => (b.ts||b.startedAt||0)-(a.ts||a.startedAt||0));
4608
4806
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
4609
- res.end(JSON.stringify(allSessions.slice(0,100)));
4807
+ res.end(JSON.stringify(allSessions.slice(0, limitParam)));
4610
4808
  } catch (_) { res.writeHead(200); res.end('[]'); }
4611
4809
  return;
4612
4810
  }
@@ -4728,6 +4926,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4728
4926
  res.end(JSON.stringify({
4729
4927
  ts: Date.now(),
4730
4928
  uptime: process.uptime(),
4929
+ dir: root,
4731
4930
  sseClients: mmSseClients.size,
4732
4931
  activeOrgs: Object.keys(orgRuns).length,
4733
4932
  orgRuns,
@@ -4744,7 +4943,8 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4744
4943
  if (req.method === 'GET' && /^\/api\/orgs\/[^/]+\/runs\/current$/.test(url)) {
4745
4944
  try {
4746
4945
  const orgName = decodeURIComponent(url.split('/')[3]);
4747
- const root = projectDir || process.cwd();
4946
+ const _curQs = new URL(req.url, 'http://localhost').searchParams;
4947
+ const root = path.resolve(_curQs.get('dir') || projectDir || process.cwd());
4748
4948
  // Validate orgName
4749
4949
  if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
4750
4950
  res.writeHead(400); res.end('{"error":"invalid org name"}'); return;
@@ -4806,6 +5006,63 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true
4806
5006
  const boundPort = await bindServer(server, port);
4807
5007
  const url = `http://localhost:${boundPort}`;
4808
5008
 
5009
+ // ── One-time migration: mastermind-sessions.json → per-session JSONL ─────
5010
+ // Runs once on startup. Existing sessions in the old monolithic format are
5011
+ // split into individual JSONL files + _index.json for O(1) event writes.
5012
+ try {
5013
+ const _migDataDir = path.join(projectDir || process.cwd(), 'data');
5014
+ const _migOldFile = path.join(_migDataDir, 'mastermind-sessions.json');
5015
+ const _migSessDir = path.join(_migDataDir, 'sessions');
5016
+ const _migIndexFile = path.join(_migSessDir, '_index.json');
5017
+ if (fs.existsSync(_migOldFile) && !fs.existsSync(_migIndexFile)) {
5018
+ try {
5019
+ const _migOld = JSON.parse(fs.readFileSync(_migOldFile, 'utf8'));
5020
+ fs.mkdirSync(_migSessDir, { recursive: true });
5021
+ const _migIndex = [];
5022
+ for (const sess of (_migOld || [])) {
5023
+ const _msid = String(sess.id || '').trim();
5024
+ if (!_msid || !/^[a-zA-Z0-9_.-]+$/.test(_msid)) continue;
5025
+ // Write per-session JSONL
5026
+ const _mEvts = (sess.events || []);
5027
+ const _mLines = _mEvts.map(e => JSON.stringify(e)).join('\n');
5028
+ fs.writeFileSync(path.join(_migSessDir, `${_msid}.jsonl`), _mLines + (_mLines ? '\n' : ''));
5029
+ _migIndex.push({ id: _msid, ts: sess.ts, prompt: sess.prompt || '',
5030
+ status: sess.status || 'complete', org: sess.org || '',
5031
+ startedAt: sess.ts || sess.startedAt, endedAt: sess.endTs || sess.endedAt,
5032
+ domains: sess.domains || [] });
5033
+ }
5034
+ fs.writeFileSync(_migIndexFile, JSON.stringify(_migIndex));
5035
+ console.log('[server] migrated ' + _migIndex.length + ' sessions to per-session JSONL format');
5036
+ } catch(_me) { console.warn('[server] session migration failed:', _me.message); }
5037
+ }
5038
+ } catch (_) {}
5039
+
5040
+ // Rebuild activeOrgRuns from disk so event enrichment (runId injection) still works
5041
+ // after a server restart. Without this, org events emitted mid-run that lack runId
5042
+ // are broadcast without it and _odtHandleLiveEvent drops them.
5043
+ try {
5044
+ const _rbOrgsDir = path.join(projectDir || process.cwd(), '.monomind', 'orgs');
5045
+ if (fs.existsSync(_rbOrgsDir)) {
5046
+ for (const _rbOrg of fs.readdirSync(_rbOrgsDir)) {
5047
+ if (!_rbOrg || _rbOrg.startsWith('.') || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rbOrg)) continue;
5048
+ const _rbRunsDir = path.join(_rbOrgsDir, _rbOrg, 'runs');
5049
+ if (!fs.existsSync(_rbRunsDir)) continue;
5050
+ const _rbFiles = fs.readdirSync(_rbRunsDir)
5051
+ .filter(f => f.endsWith('.jsonl') && !f.endsWith('.convs.jsonl'))
5052
+ .sort().reverse();
5053
+ for (const _rbF of _rbFiles.slice(0, 5)) {
5054
+ try {
5055
+ const _rbId = _rbF.replace('.jsonl', '');
5056
+ const _rbContent = fs.readFileSync(path.join(_rbRunsDir, _rbF), 'utf8');
5057
+ const _rbLast = _rbContent.trim().split('\n').filter(Boolean).slice(-10);
5058
+ const _rbDone = _rbLast.some(l => { try { const e = JSON.parse(l); return e.type === 'run:complete' || e.type === 'org:complete'; } catch { return false; } });
5059
+ if (!_rbDone) { activeOrgRuns.set(_rbOrg, _rbId); break; }
5060
+ } catch (_) {}
5061
+ }
5062
+ }
5063
+ }
5064
+ } catch (_) {}
5065
+
4809
5066
  // ---------------------------------------------------------------- Watchers
4810
5067
  let debounceTimer = null;
4811
5068
  let pendingSections = new Set();