monomind 2.5.4 → 2.5.5

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 (71) hide show
  1. package/README.md +10 -3
  2. package/package.json +1 -1
  3. package/packages/@monomind/cli/.claude/commands/mastermind/topology.md +121 -0
  4. package/packages/@monomind/cli/.claude/commands/mastermind.md +121 -102
  5. package/packages/@monomind/cli/.claude/helpers/control-start.cjs +22 -2
  6. package/packages/@monomind/cli/.claude/helpers/control-stop.cjs +95 -0
  7. package/packages/@monomind/cli/.claude/helpers/graphify-freshen.cjs +6 -0
  8. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +13 -5
  9. package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +33 -10
  10. package/packages/@monomind/cli/.claude/helpers/utils/system-pressure.cjs +46 -0
  11. package/packages/@monomind/cli/.claude/settings.json +448 -0
  12. package/packages/@monomind/cli/README.md +10 -3
  13. package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +1 -1
  14. package/packages/@monomind/cli/dist/src/commands/agent-ops.js +1 -1
  15. package/packages/@monomind/cli/dist/src/commands/cleanup.js +53 -1
  16. package/packages/@monomind/cli/dist/src/commands/config.js +1 -1
  17. package/packages/@monomind/cli/dist/src/commands/hooks-coverage-commands.js +1 -1
  18. package/packages/@monomind/cli/dist/src/commands/hooks.js +1 -1
  19. package/packages/@monomind/cli/dist/src/commands/index.js +4 -0
  20. package/packages/@monomind/cli/dist/src/commands/init-wizard.js +3 -3
  21. package/packages/@monomind/cli/dist/src/commands/init.js +2 -2
  22. package/packages/@monomind/cli/dist/src/commands/mcp.js +2 -2
  23. package/packages/@monomind/cli/dist/src/commands/memory-admin.js +8 -7
  24. package/packages/@monomind/cli/dist/src/commands/memory-list.js +2 -2
  25. package/packages/@monomind/cli/dist/src/commands/memory.d.ts +1 -1
  26. package/packages/@monomind/cli/dist/src/commands/memory.js +4 -4
  27. package/packages/@monomind/cli/dist/src/commands/neural-core.js +1 -1
  28. package/packages/@monomind/cli/dist/src/commands/performance.js +8 -9
  29. package/packages/@monomind/cli/dist/src/commands/status.js +2 -2
  30. package/packages/@monomind/cli/dist/src/commands/swarm.js +1 -1
  31. package/packages/@monomind/cli/dist/src/index.js +1 -1
  32. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +2 -2
  33. package/packages/@monomind/cli/dist/src/init/executor.js +13 -6
  34. package/packages/@monomind/cli/dist/src/init/helpers-generator.js +66 -3
  35. package/packages/@monomind/cli/dist/src/init/shared-instructions-generator.d.ts +1 -1
  36. package/packages/@monomind/cli/dist/src/init/shared-instructions-generator.js +1 -1
  37. package/packages/@monomind/cli/dist/src/init/statusline-generator.d.ts +1 -1
  38. package/packages/@monomind/cli/dist/src/init/statusline-generator.js +2 -2
  39. package/packages/@monomind/cli/dist/src/init/types.d.ts +1 -1
  40. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +1 -1
  41. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +3 -3
  42. package/packages/@monomind/cli/dist/src/mcp-client.d.ts +13 -51
  43. package/packages/@monomind/cli/dist/src/mcp-client.js +79 -134
  44. package/packages/@monomind/cli/dist/src/mcp-server.js +4 -3
  45. package/packages/@monomind/cli/dist/src/mcp-tools/browser-tools.js +45 -19
  46. package/packages/@monomind/cli/dist/src/mcp-tools/guidance-tools.js +1 -1
  47. package/packages/@monomind/cli/dist/src/mcp-tools/hive-mind-tools.js +2 -2
  48. package/packages/@monomind/cli/dist/src/mcp-tools/hooks-intelligence.js +2 -2
  49. package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +1 -1
  50. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-compat.js +2 -2
  51. package/packages/@monomind/cli/dist/src/mcp-tools/monograph-tools.js +22 -9
  52. package/packages/@monomind/cli/dist/src/mcp-tools/neural-tools.d.ts +1 -1
  53. package/packages/@monomind/cli/dist/src/mcp-tools/neural-tools.js +3 -3
  54. package/packages/@monomind/cli/dist/src/mcp-tools/system-tools.js +1 -1
  55. package/packages/@monomind/cli/dist/src/memory/embedding-operations.js +2 -2
  56. package/packages/@monomind/cli/dist/src/memory/hnsw-operations.js +53 -26
  57. package/packages/@monomind/cli/dist/src/memory/intelligence.js +2 -2
  58. package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +7 -3
  59. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +24 -10
  60. package/packages/@monomind/cli/dist/src/memory/memory-crud.js +2 -2
  61. package/packages/@monomind/cli/dist/src/memory/memory-initializer.d.ts +2 -2
  62. package/packages/@monomind/cli/dist/src/memory/memory-initializer.js +6 -6
  63. package/packages/@monomind/cli/dist/src/memory/memory-read.js +4 -4
  64. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +12 -6
  65. package/packages/@monomind/cli/dist/src/orgrt/mailbox.d.ts +1 -0
  66. package/packages/@monomind/cli/dist/src/orgrt/mailbox.js +4 -0
  67. package/packages/@monomind/cli/dist/src/orgrt/policy.js +1 -1
  68. package/packages/@monomind/cli/dist/src/orgrt/server.js +25 -0
  69. package/packages/@monomind/cli/dist/src/ui/dashboard.html +327 -611
  70. package/packages/@monomind/cli/dist/src/ui/server.mjs +165 -22
  71. package/packages/@monomind/cli/package.json +1 -1
@@ -53,6 +53,14 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
53
53
  .nav-proj-name { font-size: 12px; font-weight: 600; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54
54
  .nav-proj-items { padding-left: 12px; border-left: 1px solid oklch(72% 0.18 75 / 0.2); margin-left: 11px; }
55
55
  .nav-no-proj { display: flex; align-items: center; gap: 8px; padding: 8px 8px 6px 10px; font-size: 11px; color: var(--text-xs); font-style: italic; }
56
+ /* collapsible global nav section */
57
+ .nav-glob-hdr { display: flex; align-items: center; gap: 6px; padding: 8px 8px 4px; cursor: pointer; user-select: none; color: var(--text-xs); }
58
+ .nav-glob-hdr:hover { color: var(--text-hi); }
59
+ .nav-glob-arrow { font-size: 9px; width: 10px; text-align: center; flex-shrink: 0; transition: transform 0.12s; }
60
+ .nav-glob-hdr.open .nav-glob-arrow { transform: rotate(90deg); }
61
+ .nav-glob-lbl { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
62
+ .nav-glob-items { display: none; flex-direction: column; }
63
+ .nav-glob-items.open { display: flex; }
56
64
  #sb-footer { padding: 10px 14px; border-top: 1px solid var(--border); }
57
65
  #sb-user { font-size: 12px; font-weight: 500; color: var(--text-mid); }
58
66
  #sb-path { font-size: 10px; color: var(--text-lo); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
@@ -349,6 +357,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
349
357
  #odt-chat-send-btn { font-size:8px; letter-spacing:1.5px; padding:4px 10px; border-radius:3px; border:1px solid oklch(62% 0.2 186 / 0.4); color:oklch(62% 0.2 186); background:transparent; cursor:pointer; font-family:inherit; flex-shrink:0; align-self:flex-end; }
350
358
  #odt-chat-send-btn:hover:not(:disabled) { background:oklch(62% 0.2 186 / 0.12); }
351
359
  #odt-chat-send-btn:disabled { opacity:0.3; cursor:default; }
360
+ #odt-chat-role-sel { background:var(--surface); color:var(--text-mid); border:1px solid var(--border); border-radius:4px; font-size:10px; font-family:var(--mono); padding:4px 5px; align-self:flex-end; max-width:110px; flex-shrink:0; }
361
+ #odt-chat-role-sel:focus { outline:none; border-color:var(--accent); }
352
362
  .cv-user-msg { align-self:flex-end; }
353
363
  .cv-user-msg .cv-bub { background:oklch(20% 0.06 220); border-left:none; border-right:2px solid oklch(62% 0.2 186); }
354
364
  .odt-mode-btn {
@@ -381,6 +391,10 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
381
391
  @media (prefers-reduced-motion: reduce) { #org-agent-drawer { transition: none; } }
382
392
  #oad-head { display: flex; align-items: center; gap: 11px; padding: 16px 16px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
383
393
  .oad-avatar { width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0; background: var(--surface-hi); object-fit: cover; }
394
+ .oad-avatar-wrap { position: relative; width: 46px; height: 46px; flex-shrink: 0; cursor: pointer; }
395
+ .oad-avatar-wrap .oad-avatar { width: 100%; height: 100%; }
396
+ .oad-avatar-edit { position: absolute; bottom: -2px; right: -2px; width: 18px; height: 18px; border-radius: 50%; background: var(--surface-hi); border: 1.5px solid var(--bg); color: var(--text-lo); display: flex; align-items: center; justify-content: center; font-size: 9px; opacity: 0; transition: opacity 0.12s; }
397
+ .oad-avatar-wrap:hover .oad-avatar-edit { opacity: 1; }
384
398
  .oad-id { flex: 1; min-width: 0; }
385
399
  .oad-name { font-size: 15px; font-weight: 600; color: var(--text-hi); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
400
  .oad-pills { display: flex; gap: 5px; margin-top: 5px; flex-wrap: wrap; }
@@ -795,27 +809,6 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
795
809
  #rp-fill { height:100%; width:100%; background:var(--accent); border-radius:2px; transform:scaleX(0); transform-origin:left; transition:transform 0.15s; }
796
810
  #rp-counter { font-size:11px; color:var(--text-lo); font-family:var(--mono); white-space:nowrap; }
797
811
 
798
- /* ── agent chat view ─────────────────────────────────────── */
799
- #view-chat .vscroll { display:flex; flex-direction:column; height:100%; overflow:hidden; padding:0; }
800
- #chat-v-bar { display:flex; align-items:center; gap:8px; padding:14px 18px 10px; border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; }
801
- #chat-v-bar-title { font-size:13px; font-weight:600; color:var(--text-hi); }
802
- #chat-v-sel { background:var(--surface); color:var(--text-mid); border:1px solid var(--border); border-radius:4px; font-size:11px; font-family:var(--mono); padding:3px 7px; cursor:pointer; max-width:300px; }
803
- #chat-v-sel:focus { outline:none; border-color:var(--accent); }
804
- #chat-v-live-dot { width:5px; height:5px; border-radius:50%; background:var(--text-xs); flex-shrink:0; margin-left:auto; transition:background 0.4s; }
805
- #chat-v-live-dot.on { background:oklch(68% 0.20 150); animation:livepulse-cv 2.2s ease-in-out infinite; }
806
- @keyframes livepulse-cv { 0%,100%{opacity:1} 50%{opacity:0.4} }
807
- #chat-v-live-lbl { font-size:9px; color:var(--text-lo); }
808
- #chat-v-feed { flex:1; overflow-y:auto; padding:12px 18px; display:flex; flex-direction:column; gap:5px; scrollbar-width:thin; scrollbar-color:var(--border) transparent; }
809
- #chat-v-feed::-webkit-scrollbar { width:4px; }
810
- #chat-v-feed::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
811
- #chat-v-empty { font-size:11px; color:var(--text-lo); text-align:center; padding:32px 0; line-height:2; }
812
- #chat-v-excerpt { flex-shrink:0; padding:7px 18px 8px; background:oklch(13% 0.01 295); border-bottom:1px solid var(--border); display:none; }
813
- #chat-v-excerpt.visible { display:block; }
814
- #chat-v-agent-bar { display:flex; align-items:center; gap:6px; padding:6px 18px 7px; border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; }
815
- #chat-v-agent-lbl { font-size:8px; letter-spacing:2px; color:var(--text-xs); flex-shrink:0; }
816
- .cv-agent-pill { font-size:8px; padding:2px 8px; border-radius:10px; border:1px solid var(--border); color:var(--text-lo); background:transparent; cursor:pointer; letter-spacing:0.3px; font-family:var(--mono); transition:border-color 0.15s,color 0.15s; }
817
- .cv-agent-pill:hover { border-color:oklch(62% 0.20 186 / 0.4); color:var(--text-mid); }
818
- .cv-agent-pill.active { border-color:oklch(62% 0.20 186); color:oklch(62% 0.20 186); background:oklch(62% 0.20 186 / 0.07); }
819
812
  .cv-session-hdr { padding:10px 18px 8px; border-bottom:1px solid var(--border); margin-bottom:4px; background:oklch(13% 0.01 295); }
820
813
  .cv-shdr-prompt { font-size:12px; color:var(--text-mid); margin-bottom:4px; font-weight:500; }
821
814
  .cv-shdr-meta { display:flex; flex-wrap:wrap; gap:6px; align-items:center; font-size:9px; color:var(--text-lo); font-family:var(--mono); }
@@ -1582,22 +1575,25 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
1582
1575
  </div>
1583
1576
  <div class="nav-no-proj" id="nav-no-proj-hint">Select a project above</div>
1584
1577
  <div class="nav-sect" style="margin-top:auto;padding-top:8px;">
1585
- <div class="nav-item" data-view="global">
1586
- <span class="ico">⊕</span><span class="lbl">Global Feed</span>
1578
+ <div class="nav-glob-hdr" id="nav-glob-hdr" onclick="toggleGlobalNav()">
1579
+ <span class="nav-glob-arrow" id="nav-glob-arrow">▸</span>
1580
+ <span class="nav-glob-lbl">Global</span>
1587
1581
  </div>
1588
- <div class="nav-item" data-view="global-loops" title="Loops across all projects">
1589
- <span class="ico">↺</span><span class="lbl">Global Loops</span>
1590
- <span class="bdg" id="bdg-global-loops">—</span>
1591
- </div>
1592
- <div class="nav-item" data-view="global-tokens" title="Token usage across all projects">
1593
- <span class="ico">$</span><span class="lbl">Global Tokens</span>
1594
- </div>
1595
- <div class="nav-item" data-view="chat" title="All agent sessions across projects">
1596
- <span class="ico">⌘</span><span class="lbl">Global Agent Chat</span>
1597
- </div>
1598
- <div class="nav-item" data-view="humaninput" title="Pending questions from agents across all orgs">
1599
- <span class="ico">❓</span><span class="lbl">Human Input</span>
1600
- <span class="bdg" id="bdg-humaninput">—</span>
1582
+ <div class="nav-glob-items" id="nav-glob-items">
1583
+ <div class="nav-item" data-view="global">
1584
+ <span class="ico">⊕</span><span class="lbl">Global Feed</span>
1585
+ </div>
1586
+ <div class="nav-item" data-view="global-tokens" title="Token usage across all projects">
1587
+ <span class="ico">$</span><span class="lbl">Global Tokens</span>
1588
+ </div>
1589
+ <div class="nav-item" data-view="humaninput" title="Pending questions from agents across all orgs">
1590
+ <span class="ico">❓</span><span class="lbl">Human Input</span>
1591
+ <span class="bdg" id="bdg-humaninput">—</span>
1592
+ </div>
1593
+ <div class="nav-item" data-view="global-orgs" title="All orgs across all projects">
1594
+ <span class="ico">⬡</span><span class="lbl">All Orgs</span>
1595
+ <span class="bdg" id="bdg-global-orgs">—</span>
1596
+ </div>
1601
1597
  </div>
1602
1598
  </div>
1603
1599
  </div>
@@ -2276,17 +2272,6 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
2276
2272
  </div>
2277
2273
  </div>
2278
2274
 
2279
- <!-- GLOBAL LOOPS -->
2280
- <div class="view" id="view-global-loops">
2281
- <div class="vscroll">
2282
- <div style="display:flex;align-items:baseline;gap:10px;margin-bottom:4px">
2283
- <div class="pg-title" style="margin-bottom:0">Global Loops</div>
2284
- <span class="pg-sub" id="gl-sub" style="margin-bottom:0">Loops across all projects</span>
2285
- </div>
2286
- <div id="gl-content" style="margin-top:16px"><div class="loading-txt">Loading…</div></div>
2287
- </div>
2288
- </div>
2289
-
2290
2275
  <!-- GLOBAL TOKENS -->
2291
2276
  <div class="view" id="view-global-tokens">
2292
2277
  <div class="vscroll">
@@ -2298,23 +2283,14 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
2298
2283
  </div>
2299
2284
  </div>
2300
2285
 
2301
- <div class="view" id="view-chat">
2286
+ <!-- GLOBAL ORGS -->
2287
+ <div class="view" id="view-global-orgs">
2302
2288
  <div class="vscroll">
2303
- <div id="chat-v-bar">
2304
- <span id="chat-v-bar-title">Agent Chat</span>
2305
- <select id="chat-v-sel" onchange="chatVSelectSession(this.value)">
2306
- <option value="">— select a session —</option>
2307
- </select>
2308
- <select id="chat-v-org-run-sel" style="display:none" onchange="chatVSelectOrgRun(this.value)" title="Select org run to replay communications">
2309
- <option value="">— org run history —</option>
2310
- </select>
2311
- <div id="chat-v-live-dot"></div>
2312
- <span id="chat-v-live-lbl">OFFLINE</span>
2313
- </div>
2314
- <div id="chat-v-excerpt" class="cv-excerpt-banner"></div>
2315
- <div id="chat-v-feed">
2316
- <div id="chat-v-empty">Select a session above to see agent communications.<br>Agent messages, intercom signals, and system events appear here in real time.</div>
2289
+ <div style="display:flex;align-items:baseline;gap:10px;margin-bottom:4px">
2290
+ <div class="pg-title" style="margin-bottom:0">All Orgs</div>
2291
+ <span class="pg-sub" id="go-sub" style="margin-bottom:0">Orgs across all projects</span>
2317
2292
  </div>
2293
+ <div id="go-content" style="margin-top:16px"><div class="loading-txt">Loading…</div></div>
2318
2294
  </div>
2319
2295
  </div>
2320
2296
 
@@ -2325,6 +2301,10 @@ select.pb-cfg-inp { padding: 4px 7px; cursor: pointer; }
2325
2301
  <div class="pg-title" style="margin-bottom:0">Human Input</div>
2326
2302
  <span class="pg-sub" id="hi-sub" style="margin-bottom:0">Pending questions across all projects</span>
2327
2303
  </div>
2304
+ <div class="hi-tab-bar" style="display:flex;gap:4px;margin-top:12px;border-bottom:1px solid var(--border);padding-bottom:6px">
2305
+ <button class="odt-btn active" data-hitab="pending" onclick="hiSwitchTab('pending')">Pending <span style="font-size:10px;background:var(--surface-hi);color:var(--text-lo);border-radius:8px;padding:1px 6px;min-width:18px;display:inline-block;text-align:center" id="hi-tab-count-pending">—</span></button>
2306
+ <button class="odt-btn" data-hitab="answered" onclick="hiSwitchTab('answered')">Answered <span style="font-size:10px;background:var(--surface-hi);color:var(--text-lo);border-radius:8px;padding:1px 6px;min-width:18px;display:inline-block;text-align:center" id="hi-tab-count-answered">—</span></button>
2307
+ </div>
2328
2308
  <div id="hi-content" style="margin-top:16px"><div class="loading-txt">Loading…</div></div>
2329
2309
  </div>
2330
2310
  </div>
@@ -2493,25 +2473,44 @@ document.querySelectorAll('.nav-item[data-view]').forEach(el => {
2493
2473
  el.addEventListener('click', () => switchView(el.dataset.view));
2494
2474
  });
2495
2475
 
2476
+ // ── collapsible global nav section ──────────────────────────
2477
+ const GLOBAL_VIEWS = ['global', 'global-tokens', 'global-orgs', 'humaninput'];
2478
+ function setGlobalNavOpen(open) {
2479
+ const items = document.getElementById('nav-glob-items');
2480
+ const hdr = document.getElementById('nav-glob-hdr');
2481
+ const arrow = document.getElementById('nav-glob-arrow');
2482
+ if (!items || !hdr) return;
2483
+ items.classList.toggle('open', open);
2484
+ hdr.classList.toggle('open', open);
2485
+ if (arrow) arrow.textContent = open ? '▾' : '▸';
2486
+ }
2487
+ function toggleGlobalNav() {
2488
+ const items = document.getElementById('nav-glob-items');
2489
+ const open = !(items && items.classList.contains('open'));
2490
+ setGlobalNavOpen(open);
2491
+ try { localStorage.setItem('nav-global-open', open ? '1' : '0'); } catch(_) {}
2492
+ }
2493
+ try { setGlobalNavOpen(localStorage.getItem('nav-global-open') === '1'); } catch(_) { setGlobalNavOpen(false); }
2494
+
2496
2495
  document.getElementById('feed-scroll').addEventListener('scroll', () => {
2497
2496
  userScrolled = document.getElementById('feed-scroll').scrollTop > 50;
2498
2497
  });
2499
2498
 
2500
2499
  function switchView(v, { updateHash = true } = {}) {
2501
2500
  currentView = v;
2501
+ if (GLOBAL_VIEWS.includes(v)) setGlobalNavOpen(true);
2502
2502
  document.querySelectorAll('.nav-item[data-view]').forEach(el =>
2503
2503
  el.classList.toggle('active', el.dataset.view === v));
2504
2504
  document.querySelectorAll('.view').forEach(el =>
2505
2505
  el.classList.toggle('active', el.id === 'view-' + v));
2506
- const titles = { now:'Now', projects:'Projects', sessions:'Sessions', loops:'Loops', tokens:'Tokens', memory:'Memory', orgs:'Orgs', monograph:'Monograph', global:'Global Feed', 'global-loops':'Global Loops', 'global-tokens':'Global Tokens', chat:'Global Agent Chat', humaninput:'Human Input' };
2506
+ const titles = { now:'Now', projects:'Projects', sessions:'Sessions', loops:'Loops', tokens:'Tokens', memory:'Memory', orgs:'Orgs', monograph:'Monograph', global:'Global Feed', 'global-tokens':'Global Tokens', 'global-orgs':'All Orgs', humaninput:'Human Input' };
2507
2507
  document.getElementById('view-title').textContent = titles[v] || v;
2508
2508
  const PROJECT = DIR ? shortPath(DIR) : 'monomind';
2509
- const VIEW_LABELS = { now: 'Now', sessions: 'Sessions', projects: 'Projects', loops: 'Loops', tokens: 'Tokens', memory: 'Memory', orgs: 'Orgs', monograph: 'Monograph', global: 'Global Feed', 'global-loops': 'Global Loops', 'global-tokens': 'Global Tokens', chat: 'Global Agent Chat', humaninput: 'Human Input' };
2509
+ const VIEW_LABELS = { now: 'Now', sessions: 'Sessions', projects: 'Projects', loops: 'Loops', tokens: 'Tokens', memory: 'Memory', orgs: 'Orgs', monograph: 'Monograph', global: 'Global Feed', 'global-tokens': 'Global Tokens', 'global-orgs': 'All Orgs', humaninput: 'Human Input' };
2510
2510
  document.title = `monomind · ${PROJECT} · ${VIEW_LABELS[v] || v}`;
2511
2511
  if (updateHash) _pushHash();
2512
2512
  // Projects always re-fetches so onclick paths in cards stay current
2513
2513
  if (v === 'projects') { renderProjects(); return; }
2514
- if (v === 'chat') { initChatView(); return; }
2515
2514
  if (!viewRendered[v]) { renderView(v); viewRendered[v] = true; }
2516
2515
  }
2517
2516
 
@@ -2567,8 +2566,8 @@ function renderView(v) {
2567
2566
  if (v === 'orgs') renderOrgs();
2568
2567
  if (v === 'monograph') loadMonograph();
2569
2568
  if (v === 'global') renderGlobalFeed();
2570
- if (v === 'global-loops') renderGlobalLoops();
2571
2569
  if (v === 'global-tokens') renderGlobalTokens();
2570
+ if (v === 'global-orgs') renderGlobalOrgs();
2572
2571
  if (v === 'humaninput') renderHumanInputView();
2573
2572
  }
2574
2573
 
@@ -3997,88 +3996,57 @@ function buildRecap(events, sess) {
3997
3996
  recap.className = 'show';
3998
3997
  }
3999
3998
 
4000
- // ── global loops (multi-project) ───────────────────────────
4001
- function deduplicateLoops(loops) {
4002
- const hasRepeat = loops.some(l => l.source === '_repeat.md');
4003
- const repeatPrompts = new Set(loops.filter(l => l.source === '_repeat.md').map(l => (l.prompt || '').trim()));
4004
- return loops.filter(l => {
4005
- if (l.source === 'scheduled_tasks_lock' && hasRepeat) return false;
4006
- if (l.source !== 'schedule_wakeup_hook') return true;
4007
- const m = (l.prompt || '').match(/--loop\s+\S+\s+(.+)$/s);
4008
- return !m || !repeatPrompts.has(m[1].trim());
4009
- });
3999
+ // ── global orgs (multi-project) ──────────────────────────
4000
+ function openGlobalOrg(name, projectDir) {
4001
+ switchProject(projectDir);
4002
+ switchView('orgs');
4003
+ const trySelect = (attempts) => {
4004
+ if (typeof _v2Orgs !== 'undefined' && _v2Orgs && _v2Orgs.some(o => o.name === name)) {
4005
+ v2SelectOrg(name);
4006
+ } else if (attempts > 0) {
4007
+ setTimeout(() => trySelect(attempts - 1), 200);
4008
+ }
4009
+ };
4010
+ trySelect(20);
4010
4011
  }
4011
4012
 
4012
- async function renderGlobalLoops() {
4013
- const el = document.getElementById('gl-content');
4013
+ async function renderGlobalOrgs() {
4014
+ const el = document.getElementById('go-content');
4014
4015
  el.innerHTML = '<div class="loading-txt">Loading all projects…</div>';
4015
4016
  try {
4016
- const data = await apiFetch('/api/projects');
4017
- const allProjects = data?.projects || [];
4018
- const projects = allProjects.slice(0, 20);
4019
- if (!projects.length) {
4020
- el.innerHTML = '<div class="empty"><div class="empty-ico">↺</div><div>No projects found</div></div>';
4021
- document.getElementById('bdg-global-loops').textContent = '—';
4022
- return;
4023
- }
4024
-
4025
- const results = await Promise.allSettled(
4026
- projects.map(p => apiFetch('/api/loops?dir=' + enc(p.path)).then(d => ({
4027
- project: p,
4028
- loops: deduplicateLoops(Array.isArray(d) ? d : (d.loops || []))
4029
- })))
4030
- );
4031
-
4032
- let totalLoops = 0;
4033
- const sections = [];
4034
- for (const r of results) {
4035
- if (r.status !== 'fulfilled') continue;
4036
- const { project, loops } = r.value;
4037
- if (!loops.length) continue;
4038
- totalLoops += loops.length;
4039
- const projName = project.name || project.slug || project.path?.split('/').pop() || '?';
4040
- const rows = loops.map(l => {
4041
- const isTillend = l.type === 'tillend';
4042
- const curRep = l.currentRep || 0;
4043
- const maxReps = l.maxReps || 0;
4044
- const nextAt = l.nextRunAt ? parseInt(l.nextRunAt, 10) : 0;
4045
- const isHil = l.status === 'hil:pending';
4046
- const isExplicitlyActive = l.status === 'running' || l.status === 'waiting' || l.status === 'active';
4047
- const isOverdue = !l.status?.startsWith('hil') && !isExplicitlyActive && nextAt > 0 && nextAt <= Date.now();
4048
- const isFinished = isOverdue || (maxReps > 0 && curRep >= maxReps) ||
4049
- ['finished','done','complete','completed','expired'].includes(l.status);
4050
- const running = !isFinished && l.status !== 'stopped' && l.status !== 'paused';
4051
- const name = l.name || (l.prompt || 'loop').split('--')[0].trim().slice(0, 60);
4052
- const intervalStr = fmtInterval(l.interval || l.schedule);
4053
- const type = esc(l.type || 'repeat');
4054
- const statusClass = isHil ? 'hil' : (running ? 'active' : 'stopped');
4055
- const statusLabel = isHil ? '⚠ HIL' : (running ? 'active' : (isFinished ? 'done' : 'stopped'));
4056
- const overdueSpan = isOverdue ? ' <span class="loop-cdown overdue">overdue</span>' : '';
4057
- const metaStr = intervalStr ? esc(intervalStr) + overdueSpan : overdueSpan;
4058
- return `<div class="loop-row" style="cursor:default">
4059
- <div class="loop-ico">${isTillend ? '∞' : '↺'}</div>
4060
- <div class="loop-body">
4061
- <div class="loop-name" title="${esc(l.name || l.prompt || '')}"><span class="loop-type-badge${isTillend ? ' tillend' : ''}">${type}</span>${esc(name)}</div>
4062
- <div class="loop-meta">${metaStr}</div>
4063
- </div>
4064
- <span class="gf-proj-tag">${esc(projName)}</span>
4065
- <div class="loop-status ${statusClass}">${statusLabel}</div>
4066
- </div>`;
4067
- }).join('');
4068
- sections.push(`<div style="margin-bottom:18px">
4069
- <div class="m-group-title" style="margin-bottom:6px">${esc(projName)}</div>
4070
- ${rows}
4071
- </div>`);
4072
- }
4017
+ const data = await apiFetch('/api/orgs');
4018
+ const orgs = sortOrgList(Array.isArray(data) ? data : (data.orgs || []));
4019
+ const running = orgs.filter(o => o.running).length;
4073
4020
 
4074
- document.getElementById('bdg-global-loops').textContent = totalLoops || '—';
4075
- document.getElementById('gl-sub').textContent = `${totalLoops} loop${totalLoops !== 1 ? 's' : ''} across ${sections.length} project${sections.length !== 1 ? 's' : ''}`;
4021
+ document.getElementById('bdg-global-orgs').textContent = orgs.length || '—';
4022
+ document.getElementById('go-sub').textContent = running > 0
4023
+ ? `${orgs.length} org${orgs.length !== 1 ? 's' : ''} · ${running} running`
4024
+ : `${orgs.length} org${orgs.length !== 1 ? 's' : ''} across all projects`;
4076
4025
 
4077
- if (!sections.length) {
4078
- el.innerHTML = '<div class="empty"><div class="empty-ico">↺</div><div>No loops found across projects</div></div>';
4026
+ if (!orgs.length) {
4027
+ el.innerHTML = '<div class="empty"><div class="empty-ico">⬡</div><div>No orgs found across projects</div></div>';
4079
4028
  return;
4080
4029
  }
4081
- el.innerHTML = sections.join('');
4030
+
4031
+ el.innerHTML = orgs.map(o => {
4032
+ const rolesArr = Array.isArray(o.roles) ? o.roles : [];
4033
+ const rolesN = rolesArr.length || (typeof o.roles === 'number' ? o.roles : 0);
4034
+ const goalSnip = (o.goal || '').slice(0, 60) + ((o.goal || '').length > 60 ? '…' : '');
4035
+ const projName = o.projectDir ? (o.projectDir.split('/').filter(Boolean).pop() || o.projectDir) : '?';
4036
+ return `<div class="org-item" data-org="${esc(o.name)}" data-projdir="${esc(o.projectDir || '')}" onclick="openGlobalOrg(this.dataset.org, this.dataset.projdir)">
4037
+ <div class="oi-dot ${o.running ? 'running' : ''}"></div>
4038
+ <div class="oi-body">
4039
+ <div class="oi-name">${esc(o.name)}</div>
4040
+ ${goalSnip ? `<div class="oi-goal">${esc(goalSnip)}</div>` : ''}
4041
+ <div class="oi-chips">
4042
+ ${o.running ? `<span class="oi-chip ${o.lastEventAt ? (Date.now()-o.lastEventAt<1800000?'live':'quiet') : 'quiet'}">${o.lastEventAt ? (Date.now()-o.lastEventAt<1800000?'🟢 LIVE':'🟡 QUIET') : '🟡 QUIET'}</span>` : ''}
4043
+ <span class="oi-chip">${rolesN} roles</span>
4044
+ <span class="oi-chip">${esc(o.topology || 'hierarchical')}</span>
4045
+ <span class="gf-proj-tag" title="${esc(o.projectDir || '')}">${esc(projName)}</span>
4046
+ </div>
4047
+ </div>
4048
+ </div>`;
4049
+ }).join('');
4082
4050
  } catch (err) {
4083
4051
  el.innerHTML = '<div class="empty">Could not load: ' + esc(err.message) + '</div>';
4084
4052
  }
@@ -4222,6 +4190,9 @@ async function renderGlobalFeed() {
4222
4190
  }
4223
4191
  }
4224
4192
 
4193
+ let _hiEntries = [];
4194
+ let _hiTab = 'pending';
4195
+
4225
4196
  async function renderHumanInputView() {
4226
4197
  const el = document.getElementById('hi-content');
4227
4198
  el.innerHTML = '<div class="loading-txt">Loading all projects…</div>';
@@ -4230,7 +4201,9 @@ async function renderHumanInputView() {
4230
4201
  const projects = (data?.projects || []).slice(0, 8);
4231
4202
  if (!projects.length) {
4232
4203
  el.innerHTML = '<div class="empty"><div class="empty-ico">❓</div><div>No projects found</div></div>';
4204
+ _hiEntries = [];
4233
4205
  updateHumanInputBadge(0);
4206
+ renderHiCurrentTab(projects.length);
4234
4207
  return;
4235
4208
  }
4236
4209
  const results = await Promise.allSettled(
@@ -4242,31 +4215,55 @@ async function renderHumanInputView() {
4242
4215
  const { project, questions } = r.value;
4243
4216
  for (const q of questions) entries.push({ project, q });
4244
4217
  }
4245
- entries.sort((a, b) => (b.q.ts || 0) - (a.q.ts || 0));
4246
- updateHumanInputBadge(entries.length);
4247
- document.getElementById('hi-sub').textContent = entries.length
4248
- ? `${entries.length} pending question(s) across ${projects.length} projects`
4249
- : `No pending questions across ${projects.length} projects`;
4250
- if (!entries.length) {
4251
- el.innerHTML = '<div class="empty"><div class="empty-ico">❓</div><div>No pending questions</div></div>';
4252
- return;
4253
- }
4254
- el.innerHTML = '<div class="sess-list">' + entries.map(({ project, q }) => `
4255
- <div class="sess-row" data-question-id="${esc(q.questionId)}" style="flex-direction:column;align-items:stretch;gap:6px">
4256
- <div style="display:flex;justify-content:space-between;gap:8px">
4257
- <div><b>${esc(q.org)}</b> · ${esc(q.role)} <span style="color:var(--text-xs)">(${esc(project.name || project.path)})</span></div>
4258
- <div style="color:var(--text-xs);font-size:11px;font-family:var(--mono)">${relTime(q.ts)}</div>
4259
- </div>
4260
- <div style="color:var(--text-hi)">${esc(q.question)}</div>
4261
- <div style="display:flex;gap:6px">
4262
- <input type="text" class="hi-answer-input" placeholder="Your answer…" style="flex:1;padding:4px 8px;font-size:12px" />
4263
- <button class="btn" style="font-size:11px" onclick="submitHumanAnswer('${esc(String(project.path).replace(/['\\]/g,'\\$&'))}','${esc(String(q.org).replace(/['\\]/g,'\\$&'))}','${esc(String(q.role).replace(/['\\]/g,'\\$&'))}','${esc(String(q.questionId).replace(/['\\]/g,'\\$&'))}',this)">Answer</button>
4264
- </div>
4265
- </div>
4266
- `).join('') + '</div>';
4218
+ entries.sort((a, b) => (b.q.answeredAt || b.q.ts || 0) - (a.q.answeredAt || a.q.ts || 0));
4219
+ _hiEntries = entries;
4220
+ updateHumanInputBadge(entries.filter(({ q }) => q.answer === null || q.answer === undefined).length);
4221
+ renderHiCurrentTab(projects.length);
4267
4222
  } catch (e) { el.innerHTML = '<div class="empty">Failed: ' + esc(e.message) + '</div>'; }
4268
4223
  }
4269
4224
 
4225
+ function hiSwitchTab(tab) {
4226
+ _hiTab = tab;
4227
+ document.querySelectorAll('.hi-tab-bar .odt-btn').forEach(b =>
4228
+ b.classList.toggle('active', b.dataset.hitab === tab));
4229
+ renderHiCurrentTab();
4230
+ }
4231
+
4232
+ function renderHiCurrentTab(projectCount) {
4233
+ const el = document.getElementById('hi-content');
4234
+ const pending = _hiEntries.filter(({ q }) => q.answer === null || q.answer === undefined);
4235
+ const answered = _hiEntries.filter(({ q }) => q.answer !== null && q.answer !== undefined);
4236
+ document.getElementById('hi-tab-count-pending').textContent = pending.length || '—';
4237
+ document.getElementById('hi-tab-count-answered').textContent = answered.length || '—';
4238
+ const entries = _hiTab === 'answered' ? answered : pending;
4239
+ const projTxt = projectCount != null ? ` across ${projectCount} project${projectCount !== 1 ? 's' : ''}` : '';
4240
+ document.getElementById('hi-sub').textContent = _hiTab === 'answered'
4241
+ ? (answered.length ? `${answered.length} answered question(s)${projTxt}` : `No answered questions${projTxt}`)
4242
+ : (pending.length ? `${pending.length} pending question(s)${projTxt}` : `No pending questions${projTxt}`);
4243
+ if (!entries.length) {
4244
+ el.innerHTML = `<div class="empty"><div class="empty-ico">❓</div><div>No ${_hiTab} questions</div></div>`;
4245
+ return;
4246
+ }
4247
+ el.innerHTML = '<div class="sess-list">' + entries.map(({ project, q }) => `
4248
+ <div class="sess-row" data-question-id="${esc(q.questionId)}" style="flex-direction:column;align-items:stretch;gap:6px">
4249
+ <div style="display:flex;justify-content:space-between;gap:8px">
4250
+ <div><b>${esc(q.org)}</b> · ${esc(q.role)} <span style="color:var(--text-xs)">(${esc(project.name || project.path)})</span></div>
4251
+ <div style="color:var(--text-xs);font-size:11px;font-family:var(--mono)">${relTime(q.ts)}</div>
4252
+ </div>
4253
+ <div style="color:var(--text-hi)">${esc(q.question)}</div>
4254
+ ${_hiTab === 'answered' ? `
4255
+ <div style="display:flex;justify-content:space-between;gap:8px;padding:6px 8px;background:var(--surface-hi);border-radius:4px">
4256
+ <div><span style="color:var(--text-xs)">Answer:</span> ${esc(q.answer)}</div>
4257
+ <div style="color:var(--text-xs);font-size:11px;font-family:var(--mono);flex-shrink:0">${relTime(q.answeredAt)}</div>
4258
+ </div>` : `
4259
+ <div style="display:flex;gap:6px">
4260
+ <input type="text" class="hi-answer-input" placeholder="Your answer…" style="flex:1;padding:4px 8px;font-size:12px" />
4261
+ <button class="btn" style="font-size:11px" onclick="submitHumanAnswer('${esc(String(project.path).replace(/['\\]/g,'\\$&'))}','${esc(String(q.org).replace(/['\\]/g,'\\$&'))}','${esc(String(q.role).replace(/['\\]/g,'\\$&'))}','${esc(String(q.questionId).replace(/['\\]/g,'\\$&'))}',this)">Answer</button>
4262
+ </div>`}
4263
+ </div>
4264
+ `).join('') + '</div>';
4265
+ }
4266
+
4270
4267
  function updateHumanInputBadge(count) {
4271
4268
  const b = document.getElementById('bdg-humaninput');
4272
4269
  if (b) b.textContent = String(count);
@@ -4284,366 +4281,16 @@ async function submitHumanAnswer(dir, org, role, questionId, btnEl) {
4284
4281
  body: JSON.stringify({ dir, org, role, questionId, answer }),
4285
4282
  });
4286
4283
  const data = await res.json();
4287
- if (data.ok) { showToast('Answered', `${org}:${role}`, 'ok'); row.remove(); }
4288
- else { showToast('Error', data.error || 'failed', 'err'); btnEl.disabled = false; }
4284
+ if (data.ok) {
4285
+ showToast('Answered', `${org}:${role}`, 'ok');
4286
+ const entry = _hiEntries.find(({ q }) => q.questionId === questionId);
4287
+ if (entry) { entry.q.answer = answer; entry.q.answeredAt = Date.now(); }
4288
+ updateHumanInputBadge(_hiEntries.filter(({ q }) => q.answer === null || q.answer === undefined).length);
4289
+ renderHiCurrentTab();
4290
+ } else { showToast('Error', data.error || 'failed', 'err'); btnEl.disabled = false; }
4289
4291
  } catch (e) { showToast('Error', e.message, 'err'); btnEl.disabled = false; }
4290
4292
  }
4291
4293
 
4292
- // ── agent chat view ────────────────────────────────────────
4293
- let chatVSessions = {}; // sessionId or groupId → session/group object
4294
- let chatVGroupMap = {}; // groupId → group object
4295
- let chatVSessionGroupMap = {}; // sessionId → groupId (for sessions inside a group)
4296
- let chatVCurrentId = null;
4297
- let chatVCurrentGroupSessions = new Set(); // session IDs belonging to the currently selected group
4298
- let chatVSseSource = null;
4299
- let chatVSeenKeys = new Set();
4300
-
4301
- function initChatView() {
4302
- loadChatViewSessions();
4303
- loadChatViewOrgRuns();
4304
- if (!chatVSseSource) connectChatViewSSE();
4305
- }
4306
-
4307
- async function loadChatViewOrgRuns() {
4308
- // Populate the org run history selector with recent org runs
4309
- const runSel = document.getElementById('chat-v-org-run-sel');
4310
- if (!runSel) return;
4311
- try {
4312
- const dir = DIR ? `?dir=${encodeURIComponent(DIR)}` : '';
4313
- const orgs = await apiFetch('/api/orgs' + dir).catch(() => []);
4314
- const orgList = Array.isArray(orgs) ? orgs : (orgs.orgs || []);
4315
- const runEntries = [];
4316
- await Promise.all(orgList.slice(0, 10).map(async org => {
4317
- const name = org.name || org;
4318
- if (!name) return;
4319
- try {
4320
- const runs = await apiFetch(`/api/org/${encodeURIComponent(name)}/runs${dir}`);
4321
- (Array.isArray(runs) ? runs : (runs.runs || [])).slice(0, 5).forEach(r => {
4322
- runEntries.push({ org: name, runId: r.runId || r.id, startedAt: r.startedAt || 0 });
4323
- });
4324
- } catch (_) {}
4325
- }));
4326
- runEntries.sort((a, b) => b.startedAt - a.startedAt);
4327
- while (runSel.options.length > 1) runSel.remove(1);
4328
- runEntries.slice(0, 20).forEach(r => {
4329
- const opt = document.createElement('option');
4330
- opt.value = JSON.stringify({ org: r.org, runId: r.runId });
4331
- const ts = r.startedAt ? new Date(r.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4332
- opt.textContent = r.org + '/' + (r.runId || '').slice(0, 12) + (ts ? ' ' + ts : '');
4333
- runSel.appendChild(opt);
4334
- });
4335
- runSel.style.display = runEntries.length ? '' : 'none';
4336
- } catch(e) { console.warn('chat org runs load failed', e); }
4337
- }
4338
-
4339
- async function chatVSelectOrgRun(value) {
4340
- if (!value) return;
4341
- let org, runId;
4342
- try { ({ org, runId } = JSON.parse(value)); } catch { return; }
4343
- const feed = document.getElementById('chat-v-feed');
4344
- const empty = document.getElementById('chat-v-empty');
4345
- if (!feed) return;
4346
- feed.innerHTML = '';
4347
- // Clear session selection — org run replay is a different mode;
4348
- // also set chatVCurrentId=null so appendChatViewEvent session-guard doesn't filter events
4349
- chatVCurrentId = null;
4350
- const sessEl = document.getElementById('chat-v-sel');
4351
- if (sessEl) sessEl.value = '';
4352
- try {
4353
- const dir = DIR ? `?dir=${encodeURIComponent(DIR)}` : '';
4354
- const events = await apiFetch(`/api/org/${encodeURIComponent(org)}/runs/${encodeURIComponent(runId)}${dir}`);
4355
- const evArr = Array.isArray(events) ? events : (events.events || []);
4356
- if (!evArr.length) { feed.appendChild(empty || document.createTextNode('No events in this run.')); return; }
4357
- evArr.forEach(ev => appendChatViewEvent(ev, false));
4358
- feed.scrollTop = feed.scrollHeight;
4359
- } catch(e) {
4360
- feed.textContent = 'Failed to load run events: ' + e.message;
4361
- }
4362
- }
4363
-
4364
- async function loadChatViewSessions() {
4365
- try {
4366
- // Snapshot SSE-seeded sessions before the async fetch — the API response may not yet
4367
- // include events that arrived via SSE right before the fetch, so we preserve their
4368
- // buffered events and merge them back after (same pattern as _odtLoadChatSessions).
4369
- const _prevChatSessions = { ...chatVSessions };
4370
- const data = await apiFetch('/api/mastermind/sessions' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
4371
- chatVSessions = {};
4372
- chatVGroupMap = {};
4373
- chatVSessionGroupMap = {};
4374
- const sel = document.getElementById('chat-v-sel');
4375
- const prev = sel.value;
4376
- while (sel.options.length > 1) sel.remove(1);
4377
- // API returns a plain array (not {sessions:{...}}) — handle both shapes for safety
4378
- const sessions = Array.isArray(data) ? data : Object.values(data.sessions || data || {});
4379
-
4380
- // Normalize and merge SSE-seeded events before grouping
4381
- sessions.forEach(s => {
4382
- if (_prevChatSessions[s.id] && (_prevChatSessions[s.id].events || []).length > (s.events || []).length) {
4383
- s.events = _prevChatSessions[s.id].events;
4384
- }
4385
- if (s.status && s.status !== 'running' && s.status !== 'complete') s.status = 'complete';
4386
- });
4387
-
4388
- // Group consecutive sessions with same normalized prompt + gap < 20 min → loop group
4389
- const _CV_MAX_LOOP_GAP = 20 * 60 * 1000;
4390
- const _cvNormPrompt = p => (p || '').replace(/^running org:\s*/i, '').toLowerCase().replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 40);
4391
- const sessAsc = [...sessions].sort((a, b) => (a.startedAt || a.ts || 0) - (b.startedAt || b.ts || 0));
4392
- const cvGroups = []; // [{id, sessions, events, totalEvents, status, prompt, startedAt, _newestAt, _normKey, _isGroup}]
4393
- sessAsc.forEach(s => {
4394
- const key = _cvNormPrompt(s.prompt);
4395
- const sAt = s.startedAt || s.ts || 0;
4396
- const last = cvGroups[cvGroups.length - 1];
4397
- if (last && last._normKey === key && key && (sAt - last._newestAt) < _CV_MAX_LOOP_GAP) {
4398
- last.sessions.push(s);
4399
- last._newestAt = sAt;
4400
- last.totalEvents += (s.events || []).length;
4401
- if (s.status === 'running') last.status = 'running';
4402
- (s.events || []).forEach(ev => last.events.push(ev));
4403
- } else {
4404
- cvGroups.push({
4405
- id: s.id,
4406
- sessions: [s],
4407
- _normKey: key,
4408
- _newestAt: sAt,
4409
- totalEvents: (s.events || []).length,
4410
- status: s.status || 'complete',
4411
- prompt: s.prompt,
4412
- startedAt: sAt,
4413
- domains: s.domains || [],
4414
- events: [...(s.events || [])],
4415
- _isGroup: false
4416
- });
4417
- }
4418
- });
4419
- // Finalize groups
4420
- cvGroups.forEach(g => {
4421
- if (g.sessions.length > 1) {
4422
- g._isGroup = true;
4423
- g.id = 'grp:' + g.sessions[0].id;
4424
- g.events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
4425
- }
4426
- });
4427
- // Sort groups newest first, register
4428
- cvGroups.sort((a, b) => b._newestAt - a._newestAt);
4429
- cvGroups.forEach(g => {
4430
- chatVSessions[g.id] = g;
4431
- chatVGroupMap[g.id] = g;
4432
- g.sessions.forEach(s => {
4433
- chatVSessions[s.id] = g; // session ID also routes to group
4434
- if (g._isGroup) chatVSessionGroupMap[s.id] = g.id;
4435
- });
4436
- const opt = document.createElement('option');
4437
- opt.value = g.id;
4438
- const ts = g._newestAt ? new Date(g._newestAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4439
- const label = (g.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 30) || g.id.slice(0, 16);
4440
- const evCount = g.totalEvents || (g.events || []).length;
4441
- const msgPart = evCount ? ' ' + evCount + 'ev' : '';
4442
- const repPart = g._isGroup ? ' ' + g.sessions.length + 'reps' : '';
4443
- opt.textContent = label + (ts ? ' ' + ts : '') + repPart + msgPart + (g.status === 'running' ? ' ●' : '');
4444
- sel.appendChild(opt);
4445
- });
4446
-
4447
- // Re-attach SSE-only sessions not yet in the API response (arrived during fetch)
4448
- Object.values(_prevChatSessions).forEach(ps => {
4449
- if (!chatVSessions[ps.id]) {
4450
- chatVSessions[ps.id] = ps;
4451
- const opt = document.createElement('option');
4452
- opt.value = ps.id;
4453
- const tsVal = ps.startedAt || 0;
4454
- const ts = tsVal ? new Date(tsVal).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4455
- const label = (ps.prompt || '').replace(/^running org:\s*/i, '').slice(0, 40) || ps.id.slice(0, 16);
4456
- opt.textContent = label + (ts ? ' ' + ts : '') + ' ●';
4457
- sel.appendChild(opt);
4458
- }
4459
- });
4460
-
4461
- // Resolve prev selection: may be a sessionId inside a group now
4462
- const prevResolved = prev ? (chatVSessionGroupMap[prev] || (chatVSessions[prev] ? prev : null)) : null;
4463
- if (prevResolved && chatVSessions[prevResolved]) {
4464
- sel.value = prevResolved;
4465
- if (!chatVCurrentId) chatVSelectSession(prevResolved);
4466
- } else {
4467
- const runningGroup = cvGroups.find(g => g.status === 'running');
4468
- if (runningGroup) { sel.value = runningGroup.id; chatVSelectSession(runningGroup.id); }
4469
- else if (cvGroups.length) { sel.value = cvGroups[0].id; chatVSelectSession(cvGroups[0].id); }
4470
- }
4471
- } catch(e) { console.warn('chat sessions load failed', e); }
4472
- }
4473
-
4474
- function chatVSelectSession(id) {
4475
- chatVCurrentId = id;
4476
- chatVCurrentGroupSessions.clear();
4477
- const feed = document.getElementById('chat-v-feed');
4478
- const empty = document.getElementById('chat-v-empty');
4479
- if (!id || !chatVSessions[id]) {
4480
- feed.innerHTML = '';
4481
- feed.appendChild(empty);
4482
- return;
4483
- }
4484
- feed.innerHTML = '';
4485
- const session = chatVSessions[id];
4486
- // Populate group session set so live SSE events from any rep appear in feed
4487
- if (session._isGroup && session.sessions) {
4488
- session.sessions.forEach(s => chatVCurrentGroupSessions.add(s.id));
4489
- }
4490
- // Inject session metadata header
4491
- const tsVal = session.startedAt || session.ts || 0;
4492
- const tsStr = tsVal ? new Date(tsVal).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
4493
- const domains = (session.domains || []);
4494
- const agents = domains.length ? domains.join(', ') : '—';
4495
- const evCount = session._isGroup ? (session.totalEvents || (session.events || []).length) : (session.events || []).length;
4496
- const agentNames = (() => {
4497
- const names = new Set();
4498
- (session.events || []).forEach(ev => {
4499
- if (ev.agent) names.add(ev.agent);
4500
- if (ev.from && ev.from !== 'system') names.add(ev.from);
4501
- });
4502
- return names.size ? [...names].join(', ') : agents;
4503
- })();
4504
- const prompt = (session.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 120) || id;
4505
- const repInfo = session._isGroup ? ' · ' + session.sessions.length + ' reps' : '';
4506
- const statusCls = session.status === 'running' ? 'color:oklch(65% 0.20 145)' : session.status === 'complete' ? 'color:var(--text-lo)' : 'color:oklch(65% 0.20 25)';
4507
- // Compute cost + duration from events
4508
- const _allEvs = session.events || [];
4509
- const _totalCost = _allEvs.reduce((acc, ev) => acc + (Number(ev.cost_usd) || 0), 0);
4510
- const _costStr = _totalCost > 0 ? '$' + _totalCost.toFixed(4) : '';
4511
- const _startTs = session.startedAt || session.ts || 0;
4512
- const _endTs = session.endedAt || (_allEvs[_allEvs.length-1]?.ts) || 0;
4513
- const _durMs = _endTs > _startTs ? _endTs - _startTs : 0;
4514
- const _durStr = _durMs > 60000 ? Math.round(_durMs/60000) + 'm' : _durMs > 0 ? Math.round(_durMs/1000) + 's' : '';
4515
- const hdr = document.createElement('div');
4516
- hdr.className = 'cv-session-hdr';
4517
- hdr.innerHTML = `<div class="cv-shdr-prompt">${esc(prompt)}${repInfo ? '<span style="color:var(--text-lo);font-size:10px"> ' + esc(repInfo) + '</span>' : ''}</div>`+
4518
- `<div class="cv-shdr-meta">`+
4519
- `<span class="cv-shdr-id" title="${esc(id)}">${esc(id)}</span>`+
4520
- `<span class="cv-shdr-sep">·</span>`+
4521
- `<span style="${statusCls}">${esc(session.status || 'unknown')}</span>`+
4522
- `<span class="cv-shdr-sep">·</span>`+
4523
- `<span title="started">${esc(tsStr)}</span>`+
4524
- (_durStr ? `<span class="cv-shdr-sep">·</span><span title="duration">${esc(_durStr)}</span>` : '')+
4525
- `<span class="cv-shdr-sep">·</span>`+
4526
- `<span title="agents">${esc(agentNames)}</span>`+
4527
- `<span class="cv-shdr-sep">·</span>`+
4528
- `<span title="event count">${evCount} events</span>`+
4529
- (_costStr ? `<span class="cv-shdr-sep">·</span><span style="color:oklch(65% 0.18 50)" title="total cost">${esc(_costStr)}</span>` : '')+
4530
- `</div>`;
4531
- feed.appendChild(hdr);
4532
- const events = session.events || [];
4533
- events.forEach(ev => {
4534
- appendChatViewEvent(ev, false);
4535
- // Pre-seed dedup set so SSE reconnect replays of historical events are swallowed
4536
- const _cvhk = (ev?.ts||'')+'|'+(ev?.type||'')+'|'+(ev?.from||ev?.role||ev?.agent||'')+'|'+(ev?.session||ev?.runId||'')+'|'+(ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
4537
- chatVSeenKeys.add(_cvhk);
4538
- });
4539
- // Populate excerpt banner with last activity for the selected session
4540
- const _cvExEl = document.getElementById('chat-v-excerpt');
4541
- if (_cvExEl) { if (events.length) renderCvExcerptBanner('chat-v-excerpt', events, session); else _cvExEl.innerHTML = ''; }
4542
- feed.scrollTop = feed.scrollHeight;
4543
- }
4544
-
4545
- function appendChatViewEvent(ev, animate) {
4546
- if (chatVCurrentId && ev.session && ev.session !== chatVCurrentId && !chatVCurrentGroupSessions.has(ev.session)) return;
4547
- const feed = document.getElementById('chat-v-feed');
4548
- if (!feed) return;
4549
- const empty = document.getElementById('chat-v-empty');
4550
- if (empty && feed.contains(empty)) feed.removeChild(empty);
4551
-
4552
- let el;
4553
- const atBottom = !animate || (feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80);
4554
- const ts = ev.ts ? new Date(ev.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
4555
- if (ev.type === 'run:start') {
4556
- el = mkCVSys('▶ Run started' + (ev.goal ? ': ' + (ev.goal.slice(0,80)) : '') + (ev.bossRole ? ' · boss: ' + (ev.bossRole) : ''), ts);
4557
- } else if (ev.type === 'run:cycle:complete') {
4558
- el = mkCVSys('◎ Cycle complete' + (ev.pending != null ? ' · ' + (String(ev.pending)) + ' tasks pending' : ''), ts);
4559
- } else if (ev.type === 'intercom') {
4560
- el = mkCVIntercom(ev.from, ev.to, ev.msg || '', ts);
4561
- } else if (ev.type === 'org:comms') {
4562
- const _cvCommsFrom = ev.from || ev.role || ev.org || '?';
4563
- // Directed messages (to set, not broadcast) → intercom bubble; broadcasts → agent bubble
4564
- if (ev.to && ev.to !== 'all') el = mkCVIntercom(_cvCommsFrom, ev.to, ev.msg || '', ts);
4565
- else el = mkCVAgent(_cvCommsFrom, ev.msg || '', ts, 'org:comms');
4566
- } else if (ev.type === 'org:agent:online') {
4567
- el = mkCVSys('[' + (ev.org || '') + '] ' + (ev.role || ev.title || '?') + ' online' + (ev.title && ev.role && ev.title !== ev.role ? ' (' + (ev.title) + ')' : ''), ts);
4568
- } else if (ev.type === 'org:checkpoint') {
4569
- el = mkCVSys('[checkpoint] ' + (ev.summary || ev.progress || ev.msg || ''), ts);
4570
- } else if (ev.type === 'org:start') {
4571
- el = mkCVSys('[org:start] ' + (ev.org || ''), ts);
4572
- } else if (ev.type === 'run:complete') {
4573
- el = mkCVSys('■ Run complete' + (ev.status ? ' [' + (ev.status) + ']' : ''), ts);
4574
- } else if (ev.type === 'org:complete') {
4575
- el = mkCVSys('■ Org complete' + (ev.org ? ' — ' + (ev.org) : ''), ts);
4576
- } else if (ev.type === 'agent:result') {
4577
- el = mkCVResult(ev.agent || ev.from || '?', ev.result || ev.msg || ev.message || ev.summary || '', ts);
4578
- } else if (ev.type === 'agent:spawn') {
4579
- const _spawnTo = ev.to || ev.agentType || '?';
4580
- const _spawnTask = ev.task || ev.briefing || '';
4581
- el = mkCVSpawn(ev.from || 'orchestrator', _spawnTo, _spawnTask, ts);
4582
- } else if (ev.type === 'agent:complete') {
4583
- // Rich completion event from capture-handler (replaces/supplements org:comms)
4584
- const _acAgent = ev.agentType || ev.role || ev.from || '?';
4585
- const _acResult = ev.result || '';
4586
- const _acCost = ev.cost_usd != null ? ' $' + Number(ev.cost_usd).toFixed(4) : '';
4587
- const _acTok = (ev.tokens_in || ev.tokens_out) ? ' · ' + ((ev.tokens_in||0)+(ev.tokens_out||0)).toLocaleString() + ' tok' : '';
4588
- const _acTools = (ev.toolCalls || []).length ? ' · [' + ev.toolCalls.slice(0,5).map(t => esc(t)).join(', ') + ']' : '';
4589
- el = mkCVResult(_acAgent + _acCost + _acTok + _acTools, _acResult, ts);
4590
- } else if (ev.type === 'agent:usage') {
4591
- // Token/cost accounting event — show as compact system line
4592
- const _auAgent = ev.role || ev.agentType || '?';
4593
- const _auCost = ev.cost_usd != null ? '$' + Number(ev.cost_usd).toFixed(4) : '';
4594
- const _auTok = (ev.tokens_in || ev.tokens_out) ? ((ev.tokens_in||0)).toLocaleString() + '↑ ' + ((ev.tokens_out||0)).toLocaleString() + '↓' : '';
4595
- el = mkCVSys('◈ ' + (_auAgent) + (_auTok ? ' · ' + _auTok : '') + (_auCost ? ' · ' + _auCost : ''), ts);
4596
- } else if (ev.type === 'agent:message') {
4597
- el = mkCVAgent(ev.agent || ev.name || ev.role || '?', ev.msg || ev.message || ev.content || '', ts, ev.type);
4598
- } else if (ev.type === 'session:start') {
4599
- el = mkCVSys('Session started' + (ev.prompt ? ': ' + (ev.prompt.slice(0,80)) : ''), ts);
4600
- } else if (ev.type === 'session:complete') {
4601
- el = mkCVSys('Session complete' + (ev.status ? ' — ' + (ev.status) : ''), ts);
4602
- } else if (ev.type === 'domain:dispatch') {
4603
- el = mkCVSys('→ ' + (ev.domain || '') + (ev.cmd ? ': ' + (ev.cmd.slice(0,80)) : ''), ts);
4604
- } else if (ev.type === 'domain:complete') {
4605
- el = mkCVSys('✓ ' + (ev.domain || '') + (ev.status ? ' [' + (ev.status) + ']' : ''), ts);
4606
- } else if (ev.type === 'loop:start') {
4607
- el = mkCVSys('◎ Loop: ' + (ev.command || '') + (ev.repeat ? ' ×' + (String(ev.repeat)) : ''), ts);
4608
- if (currentView === 'loops') renderLoops();
4609
- } else if (ev.type === 'loop:complete') {
4610
- el = mkCVSys('■ Loop done: ' + (ev.command || '') + (ev.ranReps ? ' (' + (String(ev.ranReps)) + ' runs)' : ''), ts);
4611
- if (currentView === 'loops') renderLoops();
4612
- } else if (ev.type === 'loop:tick') {
4613
- const _tickLbl = ev.rep != null
4614
- ? 'rep ' + (String(ev.rep)) + ' done' + (ev.wait != null ? ' → next in ' + (String(ev.wait)) + 's' : '')
4615
- : (ev.command || ev.loopId || ev.id || '');
4616
- el = mkCVSys('◷ ' + _tickLbl, ts);
4617
- el.dataset.evType = 'loop:tick';
4618
- if (feed.lastElementChild?.dataset?.evType === 'loop:tick') feed.lastElementChild.remove();
4619
- if (currentView === 'loops') renderLoops();
4620
- } else if (ev.type === 'loop:hil' || ev.type === 'loop:hil:waiting') {
4621
- el = mkCVSys('⚠ Loop HIL: ' + (ev.command || ev.loopId || ev.id || ''), ts);
4622
- if (currentView === 'loops') renderLoops();
4623
- } else if (ev.type === 'loop:hil:resolved') {
4624
- el = mkCVSys('✓ Loop HIL resolved: ' + (ev.loopId || ev.command || ''), ts);
4625
- if (currentView === 'loops') renderLoops();
4626
- } else if (ev.type === 'file:write') {
4627
- el = mkCVFileCard(ev.name || ev.path || '?', ev.path || '', ev.size || 0, ts);
4628
- } else if (ev.type === 'agent:edit' || ev.type === 'agent:write') {
4629
- el = mkCVTool('EDIT', ev.payload || ev.file || ev.path || '', '', 'oklch(65% 0.20 270)', ts);
4630
- } else if (ev.type === 'agent:bash') {
4631
- el = mkCVTool('BASH', ev.payload || ev.command || '', ev.output || '', 'oklch(72% 0.18 40)', ts);
4632
- } else if (ev.type === 'agent:browse') {
4633
- el = mkCVTool('BROWSE', ev.payload || ev.url || '', '', 'oklch(65% 0.15 190)', ts);
4634
- } else if (ev.type === 'org:agent:offline') {
4635
- el = mkCVSys('◌ ' + (ev.title || ev.role || '?') + ' offline' + (ev.org ? ' [' + (ev.org) + ']' : ''), ts);
4636
- } else if (ev.type === 'org:error') {
4637
- el = mkCVSys('⚠ Error: ' + (ev.msg || ev.error || ev.message || ''), ts);
4638
- el.classList.add('cv-err');
4639
- } else {
4640
- el = mkCVSys((ev.type || 'event'), ts);
4641
- }
4642
- if (animate) el.classList.add('cv-new');
4643
- feed.appendChild(el);
4644
- if (atBottom) feed.scrollTop = feed.scrollHeight;
4645
- }
4646
-
4647
4294
  function _cvExcerptText(ev) {
4648
4295
  const t = ev.type || '';
4649
4296
  if (t === 'intercom' || t === 'org:comms') return (ev.from || ev.role || '?') + ' → ' + (ev.to || '?') + ': ' + (ev.msg || ev.message || '').slice(0, 80);
@@ -4798,84 +4445,6 @@ function mkCVIntercom(from, to, text, ts) {
4798
4445
  return d;
4799
4446
  }
4800
4447
 
4801
- function connectChatViewSSE() {
4802
- if (chatVSseSource) return;
4803
- const dot = document.getElementById('chat-v-live-dot');
4804
- const lbl = document.getElementById('chat-v-live-lbl');
4805
- chatVSseSource = window.mmEventSource('/api/mastermind-stream' + (DIR ? '?project=' + encodeURIComponent(DIR) : ''));
4806
- chatVSseSource.onopen = () => { dot && dot.classList.add('on'); lbl && (lbl.textContent = 'LIVE'); };
4807
- chatVSseSource.onmessage = e => {
4808
- try { handleChatViewEvent(JSON.parse(e.data)); } catch(_) {}
4809
- };
4810
- chatVSseSource.onerror = () => {
4811
- dot && dot.classList.remove('on');
4812
- lbl && (lbl.textContent = 'OFFLINE');
4813
- const _cvSrc = chatVSseSource;
4814
- chatVSseSource = null;
4815
- if (_cvSrc) _cvSrc.close();
4816
- setTimeout(connectChatViewSSE, 5000);
4817
- };
4818
- }
4819
-
4820
- function handleChatViewEvent(ev) {
4821
- if (!ev) return;
4822
- // Deduplicate SSE reconnect replays (server replays last 50 events on every reconnect)
4823
- const dedupKey = (ev.ts||'') + '|' + (ev.type||'') + '|' + (ev.from||ev.role||ev.agent||'') + '|' + (ev.session||ev.runId||'') + '|' + (ev.result||ev.msg||ev.message||ev.summary||'').slice(0,20);
4824
- if (chatVSeenKeys.has(dedupKey)) return;
4825
- chatVSeenKeys.add(dedupKey);
4826
- if (chatVSeenKeys.size > 2000) { const _a = [...chatVSeenKeys]; chatVSeenKeys = new Set(_a.slice(1000)); }
4827
- // Org events (org:comms, org:agent:online, org:checkpoint, etc.) may carry a session field
4828
- // from the org run's session ID — route them directly to the feed if the session matches,
4829
- // or append them unconditionally when the chat view is showing the "live" placeholder.
4830
- if (ev.project && DIR && ev.project !== DIR) return;
4831
- const isOrgEvent = ev.type && (ev.type.startsWith('org:') || ev.type.startsWith('run:') || ev.type.startsWith('loop:'));
4832
- if (!ev.session && !isOrgEvent) return;
4833
- if (ev.session) {
4834
- const existingEntry = chatVSessions[ev.session]; // may be a group object
4835
- if (!existingEntry) {
4836
- // Seed events with the triggering event so it isn't lost if loadChatViewSessions
4837
- // runs before the event is flushed to disk (disk write and SSE broadcast race).
4838
- chatVSessions[ev.session] = { id: ev.session, events: [ev], startedAt: ev.ts, status: 'running', prompt: ev.prompt || '' };
4839
- loadChatViewSessions();
4840
- // Auto-switch to new session on session:start — after loadChatViewSessions it may be grouped
4841
- if (ev.type === 'session:start') {
4842
- const _cvGroupId = chatVSessionGroupMap[ev.session] || ev.session;
4843
- const _cvSel = document.getElementById('chat-v-sel');
4844
- if (_cvSel) { _cvSel.value = _cvGroupId; chatVSelectSession(_cvGroupId); }
4845
- }
4846
- } else {
4847
- // existingEntry may be a group — push event to it and update status
4848
- const target = existingEntry;
4849
- target.events = target.events || [];
4850
- target.events.push(ev);
4851
- if (ev.type === 'session:complete') {
4852
- // Mark this individual session complete; group stays running until all sessions done
4853
- if (!target._isGroup) target.status = 'complete';
4854
- else {
4855
- // Update the underlying session in group
4856
- const sub = (target.sessions || []).find(s => s.id === ev.session);
4857
- if (sub) sub.status = 'complete';
4858
- // Group stays running if any session is still running
4859
- if (!(target.sessions || []).some(s => s.status === 'running')) target.status = 'complete';
4860
- }
4861
- }
4862
- if (target._isGroup) target.totalEvents = (target.totalEvents || 0) + 1;
4863
- }
4864
- // Show event in feed if this session (or its group) is currently selected
4865
- const _cvGroupId = chatVSessionGroupMap[ev.session];
4866
- const _cvIsSelected = chatVCurrentId === ev.session || (_cvGroupId && chatVCurrentId === _cvGroupId) || chatVCurrentGroupSessions.has(ev.session);
4867
- if (_cvIsSelected) {
4868
- appendChatViewEvent(ev, true);
4869
- const _cvSess = chatVSessions[_cvGroupId || ev.session];
4870
- if (_cvSess) renderCvExcerptBanner('chat-v-excerpt', _cvSess.events || [], _cvSess);
4871
- }
4872
- } else if (isOrgEvent) {
4873
- // Org events without a Claude session ID — append to feed regardless of active view;
4874
- // #chat-v-feed is in the DOM even when hidden, so events accumulate correctly.
4875
- appendChatViewEvent(ev, true);
4876
- }
4877
- }
4878
-
4879
4448
  // ── feature 4: budget cap + desktop notification ───────────
4880
4449
  let budget = (function(){ try { return JSON.parse(localStorage.getItem('mm-budget') || '{}'); } catch { return {}; } })();
4881
4450
 
@@ -5893,11 +5462,23 @@ let _v2OrgTab = 'chart';
5893
5462
  const _v2OrgEventLog = {};
5894
5463
  let _v2ChartLayout = { pos: {}, edgeIdByPair: new Map() };
5895
5464
 
5465
+ function _orgActivityMs(o) {
5466
+ const t = o.lastEventAt || (o.loop && o.loop.last_run) || o.created_at || 0;
5467
+ return typeof t === 'number' ? t : (Number(t) || new Date(t).getTime() || 0);
5468
+ }
5469
+ // Running orgs first, then most-recently-active first
5470
+ function sortOrgList(orgs) {
5471
+ return [...orgs].sort((a, b) => {
5472
+ if (!!a.running !== !!b.running) return a.running ? -1 : 1;
5473
+ return _orgActivityMs(b) - _orgActivityMs(a);
5474
+ });
5475
+ }
5476
+
5896
5477
  async function renderOrgs() {
5897
5478
  try {
5898
5479
  const q = DIR ? '?dir=' + encodeURIComponent(DIR) : '';
5899
5480
  const data = await apiFetch('/api/orgs' + q);
5900
- _v2Orgs = Array.isArray(data) ? data : (data.orgs || []);
5481
+ _v2Orgs = sortOrgList(Array.isArray(data) ? data : (data.orgs || []));
5901
5482
  } catch (_) { _v2Orgs = []; }
5902
5483
  // Show current project name in sub-header
5903
5484
  const orgProjLabel = document.getElementById('orgs-proj-label');
@@ -5928,8 +5509,9 @@ function v2RenderOrgList() {
5928
5509
  const MAX_SHOW = 4;
5929
5510
  const shownRoles = rolesArr.slice(0, MAX_SHOW);
5930
5511
  const extraCount = rolesN - shownRoles.length;
5512
+ const _oiAvMap = v2AvatarMapFor(rolesArr);
5931
5513
  const avatarsHtml = shownRoles.length ? `<div class="oi-avatars">
5932
- ${shownRoles.map(r => `<img class="oi-av" src="${v2RoleAvatar(r)}" alt="${esc(r.name||r.id||'')}" loading="lazy" title="${esc(r.name||r.id||'')}" onerror="this.src='data/avatars/coder.svg'"/>`).join('')}
5514
+ ${shownRoles.map(r => `<img class="oi-av" src="${v2RoleAvatar(r, _oiAvMap)}" alt="${esc(r.name||r.id||'')}" loading="lazy" title="${esc(r.name||r.id||'')}" onerror="this.src='data/avatars/coder.svg'"/>`).join('')}
5933
5515
  ${extraCount > 0 ? `<div class="oi-av-more">+${extraCount}</div>` : ''}
5934
5516
  </div>` : '';
5935
5517
  return `<div class="org-item${active}" data-org="${esc(o.name)}" onclick="v2SelectOrg(this.dataset.org)">
@@ -6381,6 +5963,7 @@ function v2RenderOrgChart() {
6381
5963
 
6382
5964
  // Overlay live running status from in-memory agent SSE state for chart node indicators
6383
5965
  const _chartRunning = new Set((_v2OrgData?._agents || []).filter(a => a.status === 'running').map(a => a.id));
5966
+ const _chartAvMap = v2AvatarMapFor(roles);
6384
5967
  let nodesHTML = '';
6385
5968
  roles.forEach((role, i) => {
6386
5969
  const p = pos[role.id];
@@ -6397,13 +5980,13 @@ function v2RenderOrgChart() {
6397
5980
  const runningDot = isRunning ? `<circle class="v2-chart-running-dot" r="5.5" cx="${(R * 0.65).toFixed(1)}" cy="${(-R * 0.65).toFixed(1)}" fill="oklch(68% 0.20 150)" stroke="oklch(12% 0.008 55)" stroke-width="1.5" style="animation:blink 2s ease-in-out infinite"/>` : '';
6398
5981
  const nameY = subType ? (R * 0.55 + 2).toFixed(0) : (R * 0.55 + 8).toFixed(0);
6399
5982
  const avR = Math.round(R * 0.52);
6400
- const avatarSrc = v2RoleAvatar(role);
5983
+ const avatarSrc = v2RoleAvatar(role, _chartAvMap);
6401
5984
  nodesHTML += `<g class="v2oc-node" data-role="${esc(role.id)}" onclick="v2OpenAgent('${String(role.id).replace(/['\\]/g,'\\$&')}')" transform="translate(${p.x.toFixed(1)},${p.y.toFixed(1)})">
6402
5985
  ${runningDot}
6403
5986
  <title>${esc(displayName)}${subType ? ` · ${esc(subType)}` : ''} — click for details</title>
6404
5987
  ${outerRing}
6405
5988
  <circle r="${R}" fill="oklch(12% 0.008 55)" stroke="${color}" stroke-width="${leader ? 2.5 : 1.8}" filter="url(#v2glow)"/>
6406
- <image href="${avatarSrc}" x="${-avR}" y="${-R + 2}" width="${avR * 2}" height="${avR * 2}" clip-path="url(#${nodeClipIds[i]})" preserveAspectRatio="xMidYMid meet" onerror="this.setAttribute('href','data/avatars/coder.svg')"/>
5989
+ <image href="${avatarSrc}" x="${-avR}" y="${-avR}" width="${avR * 2}" height="${avR * 2}" clip-path="url(#${nodeClipIds[i]})" preserveAspectRatio="xMidYMid meet" onerror="this.setAttribute('href','data/avatars/coder.svg')"/>
6407
5990
  <text text-anchor="middle" y="${nameY}" font-size="9" font-weight="${leader?'600':'500'}" font-family="'Inter',system-ui,sans-serif" fill="${color}">${esc(nameText)}</text>
6408
5991
  ${subTypeText ? `<text text-anchor="middle" y="${+nameY + 11}" font-size="7.5" font-family="'Inter',system-ui,sans-serif" fill="oklch(48% 0.005 75)">${esc(subTypeText)}</text>` : ''}
6409
5992
  <foreignObject class="v2-activity-bubble" data-bubble-for="${esc(role.id)}" x="-70" y="${-R - 46}" width="140" height="34" style="overflow:visible;pointer-events:none;visibility:hidden;opacity:0">
@@ -6734,18 +6317,68 @@ const _v2TypeFallback = {
6734
6317
  reporter:'analytics-reporter', strategist:'product-manager',
6735
6318
  designer:'game-designer', writer:'technical-writer', auditor:'security-auditor',
6736
6319
  };
6737
- function v2RoleAvatar(role) {
6320
+ // Ranked list of built-in avatar ids that could represent this role, best match first.
6321
+ function _v2AvatarCandidates(role) {
6738
6322
  const id = ((role && role.id) || '').toLowerCase().replace(/\s+/g,'-');
6739
6323
  const type = ((role && (role.type || role.agent_type)) || '').toLowerCase().replace(/\s+/g,'-');
6740
- if (_v2AvatarKnown.has(id)) return `data/avatars/${id}.svg`;
6741
- if (_v2AvatarKnown.has(type)) return `data/avatars/${type}.svg`;
6324
+ const out = [];
6325
+ const add = k => { if (k && !out.includes(k)) out.push(k); };
6326
+ if (_v2AvatarKnown.has(id)) add(id);
6327
+ if (_v2AvatarKnown.has(type)) add(type);
6742
6328
  for (const k of _v2AvatarKnown) {
6743
- if (id && (id.includes(k) || k.includes(id))) return `data/avatars/${k}.svg`;
6329
+ if (id && (id.includes(k) || k.includes(id))) add(k);
6744
6330
  }
6745
6331
  for (const [t, av] of Object.entries(_v2TypeFallback)) {
6746
- if ((type && type.includes(t)) || (id && id.includes(t))) return `data/avatars/${av}.svg`;
6332
+ if ((type && type.includes(t)) || (id && id.includes(t))) add(av);
6747
6333
  }
6748
- return 'data/avatars/coder.svg';
6334
+ add('coder');
6335
+ return out;
6336
+ }
6337
+
6338
+ // Small stable string hash — used to deterministically (not randomly) pick a fallback
6339
+ // avatar for roles whose entire candidate list is already taken by a sibling role.
6340
+ function _v2StrHash(s) {
6341
+ let h = 0;
6342
+ for (let i = 0; i < String(s).length; i++) h = (h * 31 + String(s).charCodeAt(i)) >>> 0;
6343
+ return h;
6344
+ }
6345
+
6346
+ // Assigns each role in an org a distinct built-in avatar (no two roles in the same
6347
+ // org share one), while leaving any role with a custom uploaded avatar untouched.
6348
+ // Pure function of `roles` — cheap to recompute on every render, so callers should
6349
+ // NOT cache it across data reloads.
6350
+ function v2AvatarMapFor(roles) {
6351
+ const list = Array.isArray(roles) ? roles : [];
6352
+ const used = new Set();
6353
+ const map = new Map();
6354
+ const allKnown = Array.from(_v2AvatarKnown);
6355
+ list.forEach(r => {
6356
+ if (!r || !r.id) return;
6357
+ if (r.avatar) { map.set(r.id, r.avatar); return; } // custom upload — exempt from dedup
6358
+ const candidates = _v2AvatarCandidates(r);
6359
+ let pick = candidates.find(c => !used.has(c));
6360
+ if (!pick) {
6361
+ const free = allKnown.filter(k => !used.has(k));
6362
+ // Pool exhausted (org has more roles than the ~65 built-in avatars) — a genuine
6363
+ // duplicate is unavoidable at that point, but hash across the FULL pool rather
6364
+ // than always falling back to candidates[0] (which is guaranteed already taken,
6365
+ // since the .find() above just failed on it), so overflow roles at least spread
6366
+ // out instead of all collapsing onto the same one avatar.
6367
+ pick = free.length ? free[_v2StrHash(r.id) % free.length] : allKnown[_v2StrHash(r.id) % allKnown.length];
6368
+ }
6369
+ used.add(pick);
6370
+ map.set(r.id, `data/avatars/${pick}.svg`);
6371
+ });
6372
+ return map;
6373
+ }
6374
+
6375
+ // avatarMap (from v2AvatarMapFor) is optional — pass it whenever sibling roles are
6376
+ // known, so avatars stay unique within the org; without it this falls back to the
6377
+ // single best guess for this role alone.
6378
+ function v2RoleAvatar(role, avatarMap) {
6379
+ if (avatarMap && role && avatarMap.has(role.id)) return avatarMap.get(role.id);
6380
+ if (role && role.avatar) return role.avatar;
6381
+ return `data/avatars/${_v2AvatarCandidates(role)[0]}.svg`;
6749
6382
  }
6750
6383
 
6751
6384
  // ── org roles ───────────────────────────────
@@ -6764,6 +6397,7 @@ function v2RenderOrgRoles() {
6764
6397
  if (!leaderIds.size) leaderIds.add(roles[0]?.id);
6765
6398
  // Overlay live running status from in-memory agent SSE state
6766
6399
  const _rolesRunning = new Set((_v2OrgData?._agents || []).filter(a => a.status === 'running').map(a => a.id));
6400
+ const _rolesAvMap = v2AvatarMapFor(roles);
6767
6401
  pane.innerHTML = `<div class="roles-v2-grid">${roles.map((r, i) => {
6768
6402
  const color = V2_ROLE_COLORS[i % V2_ROLE_COLORS.length];
6769
6403
  const isLeader = leaderIds.has(r.id);
@@ -6771,7 +6405,7 @@ function v2RenderOrgRoles() {
6771
6405
  const resps = Array.isArray(r.responsibilities) ? r.responsibilities : [];
6772
6406
  const displayName = r.name || r.title || r.id;
6773
6407
  const roleType = r.type || r.agent_type || '';
6774
- const avatarSrc = v2RoleAvatar(r);
6408
+ const avatarSrc = v2RoleAvatar(r, _rolesAvMap);
6775
6409
  return `<div class="role-v2" style="border-top:2px solid ${color}" onclick="v2OpenAgent('${String(r.id).replace(/['\\]/g,'\\$&')}')" title="View agent characteristics, skills & instructions">
6776
6410
  <div class="rv2-head">
6777
6411
  <img class="rv2-avatar" src="${avatarSrc}" alt="${esc(displayName)}" loading="lazy" onerror="this.src='data/avatars/coder.svg'"/>
@@ -6828,10 +6462,15 @@ function v2RenderAgentDrawer(data) {
6828
6462
  const name = role.name || role.title || role.id || 'Agent';
6829
6463
  const type = role.agent_type || role.type || '';
6830
6464
  const model = (role.adapter_config && role.adapter_config.model) || cap.model_preference || '';
6831
- const avatar = v2RoleAvatar(role);
6465
+ const siblingRoles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [role];
6466
+ const avatar = v2RoleAvatar(role, v2AvatarMapFor(siblingRoles));
6832
6467
 
6833
6468
  headEl.innerHTML = `
6834
- <img class="oad-avatar" src="${avatar}" alt="${esc(name)}" onerror="this.src='data/avatars/coder.svg'"/>
6469
+ <div class="oad-avatar-wrap" onclick="v2TriggerAvatarUpload('${String(role.id).replace(/['\\]/g,'\\$&')}')" title="Change avatar">
6470
+ <img class="oad-avatar" id="oad-avatar-img" src="${avatar}" alt="${esc(name)}" onerror="this.src='data/avatars/coder.svg'"/>
6471
+ <div class="oad-avatar-edit">✎</div>
6472
+ </div>
6473
+ <input type="file" id="oad-avatar-file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none" onchange="v2HandleAvatarFile(this)"/>
6835
6474
  <div class="oad-id">
6836
6475
  <div class="oad-name">${esc(name)}</div>
6837
6476
  <div class="oad-pills">
@@ -6871,6 +6510,62 @@ function v2RenderAgentDrawer(data) {
6871
6510
  bodyEl.innerHTML = html;
6872
6511
  bodyEl.scrollTop = 0;
6873
6512
  }
6513
+
6514
+ // ── custom role avatar upload ────────────────
6515
+ function v2TriggerAvatarUpload(roleId) {
6516
+ const inp = document.getElementById('oad-avatar-file');
6517
+ if (!inp) return;
6518
+ inp.dataset.roleId = roleId;
6519
+ inp.value = '';
6520
+ inp.click();
6521
+ }
6522
+ async function v2HandleAvatarFile(inputEl) {
6523
+ const file = inputEl.files && inputEl.files[0];
6524
+ const roleId = inputEl.dataset.roleId;
6525
+ if (!file || !roleId || !_v2SelOrg) return;
6526
+ if (!/^image\//.test(file.type)) { showToast('Error', 'Please choose an image file', 'err'); return; }
6527
+ if (file.size > 5 * 1024 * 1024) { showToast('Error', 'Image too large (max 5MB)', 'err'); return; }
6528
+ try {
6529
+ const dataUrl = await v2DownscaleImageToDataUrl(file, 160);
6530
+ const res = await fetch(`/api/org/${encodeURIComponent(_v2SelOrg)}/agent/${encodeURIComponent(roleId)}/avatar${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`, {
6531
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
6532
+ body: JSON.stringify({ avatarDataUrl: dataUrl }),
6533
+ });
6534
+ const data = await res.json();
6535
+ if (!data.ok) throw new Error(data.error || 'failed');
6536
+ const roles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
6537
+ const r = roles.find(x => x.id === roleId);
6538
+ if (r) r.avatar = dataUrl;
6539
+ const imgEl = document.getElementById('oad-avatar-img');
6540
+ if (imgEl) imgEl.src = dataUrl;
6541
+ showToast('Avatar updated', roleId, 'ok');
6542
+ if (document.getElementById('odt-roles')) v2RenderOrgRoles();
6543
+ if (_v2SelOrg) v2RenderOrgChart();
6544
+ } catch (e) { showToast('Error', e.message, 'err'); }
6545
+ }
6546
+ // Downscale/center-crop an uploaded image client-side before sending — avatars are
6547
+ // stored inline as data URLs in the org config JSON, so keeping them small matters.
6548
+ function v2DownscaleImageToDataUrl(file, size) {
6549
+ return new Promise((resolve, reject) => {
6550
+ const img = new Image();
6551
+ const reader = new FileReader();
6552
+ reader.onerror = () => reject(new Error('Could not read file'));
6553
+ reader.onload = () => {
6554
+ img.onerror = () => reject(new Error('Could not decode image'));
6555
+ img.onload = () => {
6556
+ const canvas = document.createElement('canvas');
6557
+ canvas.width = size; canvas.height = size;
6558
+ const ctx = canvas.getContext('2d');
6559
+ const s = Math.min(img.width, img.height);
6560
+ ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size);
6561
+ resolve(canvas.toDataURL('image/png'));
6562
+ };
6563
+ img.src = reader.result;
6564
+ };
6565
+ reader.readAsDataURL(file);
6566
+ });
6567
+ }
6568
+
6874
6569
  // Minimal, XSS-safe markdown → HTML (headings, lists, code, bold/italic, inline code).
6875
6570
  function v2RenderMarkdown(md) {
6876
6571
  if (!md) return '';
@@ -7634,6 +7329,7 @@ async function v2RenderOrgChat() {
7634
7329
  <div id="odt-chat-empty">Select a session to see agent communications.<br><span style="font-size:10px;opacity:0.5">Sessions appear after the first /mastermind:runorg</span></div>
7635
7330
  </div>
7636
7331
  <div id="odt-chat-compose">
7332
+ <select id="odt-chat-role-sel" title="Send to"><option value="">boss (default)</option></select>
7637
7333
  <textarea id="odt-chat-input" rows="1" placeholder="Send a message to the org…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();odtSendChatMessage();}"></textarea>
7638
7334
  <button id="odt-chat-send-btn" onclick="odtSendChatMessage()" disabled>SEND</button>
7639
7335
  </div>`;
@@ -7641,6 +7337,7 @@ async function v2RenderOrgChat() {
7641
7337
 
7642
7338
  // Restore saved chat mode (runs on every tab open so buttons stay in sync)
7643
7339
  initOdtChatMode();
7340
+ populateOdtChatRoleSelect();
7644
7341
 
7645
7342
  // Reset state only when org changes (not on every tab-switch)
7646
7343
  if (_odtChatForOrg !== _v2SelOrg) {
@@ -8017,16 +7714,34 @@ function _odtPopulateChatSel() {
8017
7714
  }
8018
7715
  }
8019
7716
 
7717
+ function populateOdtChatRoleSelect() {
7718
+ const sel = document.getElementById('odt-chat-role-sel');
7719
+ if (!sel) return;
7720
+ const roles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
7721
+ const prev = sel.value;
7722
+ sel.innerHTML = '<option value="">boss (default)</option>' +
7723
+ roles.map(r => `<option value="${esc(r.id)}">${esc(r.title || r.id)}</option>`).join('');
7724
+ if (roles.some(r => r.id === prev)) sel.value = prev;
7725
+ }
7726
+
8020
7727
  window.odtSendChatMessage = async function() {
8021
7728
  const inp = document.getElementById('odt-chat-input');
8022
7729
  const btn = document.getElementById('odt-chat-send-btn');
7730
+ const roleSel = document.getElementById('odt-chat-role-sel');
8023
7731
  if (!inp || !_v2SelOrg) return;
8024
7732
  const text = inp.value.trim();
8025
7733
  if (!text) return;
7734
+ const role = roleSel ? roleSel.value : '';
8026
7735
  inp.value = '';
8027
7736
  btn.disabled = true;
8028
7737
  try {
8029
- await fetch('/api/orgs/'+encodeURIComponent(_v2SelOrg)+'/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text, runId: _odtChatCurrentId || null }) });
7738
+ const _sendQ = DIR ? '?dir=' + encodeURIComponent(DIR) : '';
7739
+ const res = await fetch('/api/orgs/'+encodeURIComponent(_v2SelOrg)+'/chat'+_sendQ, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text, role: role || undefined, runId: _odtChatCurrentId || null }) });
7740
+ const data = await res.json().catch(() => ({}));
7741
+ if (!data.delivered) {
7742
+ const lbl = document.getElementById('odt-chat-live-lbl');
7743
+ if (lbl) { const prev = lbl.textContent; lbl.textContent = 'QUEUED (org offline)'; setTimeout(() => { lbl.textContent = prev; }, 4000); }
7744
+ }
8030
7745
  } catch(e) {
8031
7746
  inp.value = text;
8032
7747
  } finally {
@@ -9183,6 +8898,7 @@ window.v2DeleteOrg = async function() {
9183
8898
  if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || 'HTTP ' + r.status); }
9184
8899
  showToast('Deleted', `Org "${name}" deleted`, 'ok');
9185
8900
  _v2SelOrg = null;
8901
+ if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
9186
8902
  const inner = document.getElementById('org-detail-inner');
9187
8903
  if (inner) inner.style.display = 'none';
9188
8904
  const noSel = document.querySelector('.orgs-no-sel');