@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.
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 +402 -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,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 from stop file absence + state
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 running = !fs.existsSync(stopFile) && Object.values(state.agents || {}).some(a => a.status === 'running');
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
- // Also include roles from org config if state is empty
4078
- if (!agents.length) {
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 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 }));
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 routinesFile = path.join(path.resolve(_routinesQs.get('dir') || projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-routines.json`);
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, 5).map(parse).filter(Boolean);
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: first?.goal || '', bossRole: first?.bossRole || '' });
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
- 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;
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 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 (_) {}
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
- // Sort by ts descending, cap at 100
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,100)));
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 root = projectDir || process.cwd();
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();