@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
@@ -302,10 +302,14 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
302
302
  .odh-pill { font-size: 11px; padding: 2px 8px; border-radius: 8px; background: var(--surface); color: var(--text-lo); border: 1px solid var(--border); }
303
303
  .odh-right { margin-left: auto; display: flex; gap: 6px; }
304
304
 
305
- #org-detail-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 16px; }
305
+ #org-detail-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: 0 16px; overflow-x: auto; overflow-y: visible; scrollbar-width: none; }
306
+ #org-detail-tabs::-webkit-scrollbar { display: none; }
306
307
  .odt-btn { font-size: 12px; padding: 8px 12px; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; color: var(--text-lo); font-family: var(--sans); transition: color 0.1s; }
307
308
  .odt-btn:hover { color: var(--text-hi); }
308
- .odt-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
309
+ .odt-btn.active { color: var(--accent); border-bottom-color: var(--accent); opacity: 1; }
310
+ .odt-btn.secondary { opacity: 0.6; }
311
+ .odt-btn.secondary.active { opacity: 1; }
312
+ .odt-tab-sep { width: 1px; background: var(--border); margin: 6px 4px; flex-shrink: 0; align-self: stretch; }
309
313
 
310
314
  #org-detail-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
311
315
  #org-detail-body::-webkit-scrollbar { width: 3px; }
@@ -325,11 +329,19 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
325
329
  #odt-chat-agent-lbl { font-size:8px; letter-spacing:2px; color:var(--text-xs); flex-shrink:0; }
326
330
  .odt-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; }
327
331
  .odt-agent-pill:hover { border-color:oklch(62% 0.20 186 / 0.4); color:var(--text-mid); }
332
+ .odt-agent-pill.dim { opacity:0.35; }
333
+ .odt-agent-pill.dim:hover { opacity:0.6; }
328
334
  .odt-agent-pill.active { border-color:oklch(62% 0.20 186); color:oklch(62% 0.20 186); background:oklch(62% 0.20 186 / 0.07); }
329
335
  #odt-chat-feed { flex:1; overflow-y:auto; padding:10px 16px; display:flex; flex-direction:column; gap:5px; scrollbar-width:thin; scrollbar-color:var(--border) transparent; }
330
336
  #odt-chat-feed::-webkit-scrollbar { width:4px; }
331
337
  #odt-chat-feed::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
332
338
  #odt-chat-empty { font-size:11px; color:var(--text-lo); text-align:center; padding:32px 0; line-height:2; }
339
+ .cv-excerpt-banner { flex-shrink:0; padding:7px 16px 8px; background:oklch(13% 0.01 295); border-bottom:1px solid var(--border); display:none; }
340
+ .cv-excerpt-banner.visible { display:block; }
341
+ .cv-excerpt-label { font-size:8px; letter-spacing:2px; color:var(--text-xs); text-transform:uppercase; margin-bottom:4px; }
342
+ .cv-excerpt-items { display:flex; flex-direction:column; gap:2px; }
343
+ .cv-excerpt-item { font-size:10px; color:var(--text-lo); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
344
+ .cv-excerpt-item .cv-ex-tag { font-size:8px; color:var(--text-xs); margin-right:5px; display:inline-block; min-width:30px; }
333
345
 
334
346
  /* agent detail drawer (slides in from right within the org detail pane) */
335
347
  #org-agent-drawer {
@@ -711,6 +723,18 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
711
723
  #chat-v-feed::-webkit-scrollbar { width:4px; }
712
724
  #chat-v-feed::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
713
725
  #chat-v-empty { font-size:11px; color:var(--text-lo); text-align:center; padding:32px 0; line-height:2; }
726
+ #chat-v-excerpt { flex-shrink:0; padding:7px 18px 8px; background:oklch(13% 0.01 295); border-bottom:1px solid var(--border); display:none; }
727
+ #chat-v-excerpt.visible { display:block; }
728
+ #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; }
729
+ #chat-v-agent-lbl { font-size:8px; letter-spacing:2px; color:var(--text-xs); flex-shrink:0; }
730
+ .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; }
731
+ .cv-agent-pill:hover { border-color:oklch(62% 0.20 186 / 0.4); color:var(--text-mid); }
732
+ .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); }
733
+ .cv-session-hdr { padding:10px 18px 8px; border-bottom:1px solid var(--border); margin-bottom:4px; background:oklch(13% 0.01 295); }
734
+ .cv-shdr-prompt { font-size:12px; color:var(--text-mid); margin-bottom:4px; font-weight:500; }
735
+ .cv-shdr-meta { display:flex; flex-wrap:wrap; gap:6px; align-items:center; font-size:9px; color:var(--text-lo); font-family:var(--mono); }
736
+ .cv-shdr-id { color:var(--text-xs); font-size:8px; }
737
+ .cv-shdr-sep { color:var(--border); }
714
738
  .cv-msg { display:flex; flex-direction:column; max-width:90%; }
715
739
  .cv-msg.cv-sys { align-self:center; max-width:100%; }
716
740
  .cv-msg.cv-agent { align-self:flex-start; }
@@ -1814,78 +1838,53 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
1814
1838
  <span class="odh-pill" id="odh-topo">—</span>
1815
1839
  <span class="odh-pill" id="odh-roles">0 roles</span>
1816
1840
  <div class="odh-right">
1841
+ <button class="btn" onclick="v2ExportOrg()" title="Export org as JSON file" style="color:var(--text-lo);border-color:var(--border)">Export</button>
1842
+ <button class="btn" onclick="v2ImportOrgTrigger()" title="Import org from JSON file" style="color:var(--text-lo);border-color:var(--border)">Import</button>
1817
1843
  <button class="btn" id="org-copy-btn" onclick="v2ShowCopyOrgDialog()" style="color:var(--accent);border-color:var(--accent)">Copy to…</button>
1818
1844
  <button class="btn" id="org-stop-btn" onclick="v2StopOrg()" style="display:none;color:var(--red);border-color:oklch(60% 0.18 25 / 0.4)">Stop</button>
1845
+ <button class="btn" id="org-delete-btn" onclick="v2DeleteOrg()" title="Permanently delete this org and all its data" style="color:var(--red);border-color:oklch(60% 0.18 25 / 0.4)">Delete</button>
1819
1846
  </div>
1820
1847
  </div>
1821
1848
  <div id="org-detail-tabs">
1822
1849
  <button class="odt-btn active" data-tab="chart" onclick="v2SwitchOrgTab('chart')">Chart</button>
1823
- <button class="odt-btn" data-tab="activity" onclick="v2SwitchOrgTab('activity')">Activity</button>
1824
- <button class="odt-btn" data-tab="health" onclick="v2SwitchOrgTab('health')">Health</button>
1825
- <button class="odt-btn" data-tab="approvals" onclick="v2SwitchOrgTab('approvals')">Approvals</button>
1826
- <button class="odt-btn" data-tab="budgets" onclick="v2SwitchOrgTab('budgets')">Budgets</button>
1827
- <button class="odt-btn" data-tab="charts" onclick="v2SwitchOrgTab('charts')">Charts</button>
1828
- <button class="odt-btn" data-tab="skills" onclick="v2SwitchOrgTab('skills')">Skills</button>
1829
1850
  <button class="odt-btn" data-tab="chat" onclick="v2SwitchOrgTab('chat')">Chat</button>
1830
- <button class="odt-btn" data-tab="config" onclick="v2SwitchOrgTab('config')">Config</button>
1831
- <button class="odt-btn" data-tab="files" onclick="v2SwitchOrgTab('files')">Files</button>
1851
+ <button class="odt-btn" data-tab="agents-full" onclick="v2SwitchOrgTab('agents-full')">Agents</button>
1852
+ <button class="odt-btn" data-tab="activity" onclick="v2SwitchOrgTab('activity')">Activity</button>
1853
+ <div class="odt-tab-sep" title="Secondary tabs"></div>
1854
+ <button class="odt-btn secondary" data-tab="skills" onclick="v2SwitchOrgTab('skills')">Skills</button>
1855
+
1856
+ <button class="odt-btn secondary" data-tab="costs" onclick="v2SwitchOrgTab('costs')">Costs</button>
1857
+ <button class="odt-btn secondary" data-tab="config" onclick="v2SwitchOrgTab('config')">Config</button>
1858
+ <button class="odt-btn secondary" data-tab="files" onclick="v2SwitchOrgTab('files')">Files</button>
1832
1859
  </div>
1833
1860
  <div id="org-detail-body">
1834
1861
  <div class="odt-pane active" id="odt-chart"></div>
1835
- <div class="odt-pane" id="odt-roles"></div>
1836
- <div class="odt-pane" id="odt-activity"></div>
1837
- <div class="odt-pane" id="odt-health"></div>
1838
- <div class="odt-pane" id="odt-heartbeats"></div>
1839
- <div class="odt-pane" id="odt-tasks"></div>
1840
- <div class="odt-pane" id="odt-costs"></div>
1841
- <div class="odt-pane" id="odt-members"></div>
1842
- <div class="odt-pane" id="odt-goals"></div>
1843
- <div class="odt-pane" id="odt-board"></div>
1844
- <div class="odt-pane" id="odt-live"></div>
1845
- <div class="odt-pane" id="odt-approvals"></div>
1846
- <div class="odt-pane" id="odt-secrets"></div>
1847
- <div class="odt-pane" id="odt-settings"></div>
1848
- <div class="odt-pane" id="odt-routines"></div>
1849
- <div class="odt-pane" id="odt-myissues"></div>
1850
- <div class="odt-pane" id="odt-budgets"></div>
1851
- <div class="odt-pane" id="odt-plugins"></div>
1852
- <div class="odt-pane" id="odt-charts"></div>
1853
- <div class="odt-pane" id="odt-projects"></div>
1854
- <div class="odt-pane" id="odt-skills"></div>
1855
- <div class="odt-pane" id="odt-workspaces"></div>
1856
- <div class="odt-pane" id="odt-invites"></div>
1857
1862
  <div class="odt-pane" id="odt-agents-full"></div>
1863
+ <div class="odt-pane" id="odt-activity"></div>
1858
1864
  <div class="odt-pane" id="odt-chat" style="flex-direction:column;height:100%;overflow:hidden;"></div>
1865
+ <div class="odt-pane" id="odt-skills"></div>
1866
+ <div class="odt-pane" id="odt-costs"></div>
1859
1867
  <div class="odt-pane" id="odt-config" style="overflow-y:auto"></div>
1860
1868
  <div class="odt-pane" id="odt-files" style="overflow-y:auto"></div>
1861
- <div class="odt-pane" id="odt-environments"></div>
1862
- <div class="odt-pane" id="odt-access"></div>
1863
- <div class="odt-pane" id="odt-issues-full"></div>
1864
- <div class="odt-pane" id="odt-join-requests"></div>
1865
- <div class="odt-pane" id="odt-threads"></div>
1866
1869
  </div>
1867
1870
  </div>
1868
- <div id="org-copy-dialog" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,0.55);z-index:90;align-items:center;justify-content:center">
1869
- <div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;width:320px;display:flex;flex-direction:column;gap:14px">
1871
+ <div id="org-copy-dialog" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,0.72);z-index:90;align-items:center;justify-content:center">
1872
+ <div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;width:380px;max-width:96vw;display:flex;flex-direction:column;gap:12px">
1870
1873
  <div style="font-size:13px;font-weight:600;color:var(--text-hi)">Copy org to project</div>
1871
- <div><input id="org-copy-dest" class="filter-input" placeholder="Destination project path…" style="width:100%"></div>
1874
+ <div style="font-size:10px;color:var(--text-lo);letter-spacing:.05em;margin-bottom:2px">SELECT PROJECT</div>
1875
+ <div id="org-copy-proj-list" style="max-height:160px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;background:var(--bg)"></div>
1876
+ <div style="font-size:10px;color:var(--text-lo);letter-spacing:.05em;margin-top:2px">OR ENTER PATH</div>
1877
+ <input id="org-copy-dest" class="filter-input" placeholder="Destination project path…" style="width:100%;box-sizing:border-box"
1878
+ oninput="document.querySelectorAll('#org-copy-proj-list .ocp-row').forEach(r=>r.classList.remove('ocp-sel'))"
1879
+ onkeydown="if(event.key==='Enter')v2DoCopyOrg();if(event.key==='Escape')document.getElementById('org-copy-dialog').style.display='none'">
1880
+ <div id="org-copy-status" style="font-size:11px;min-height:16px"></div>
1872
1881
  <div style="display:flex;gap:8px;justify-content:flex-end">
1873
1882
  <button class="btn" onclick="document.getElementById('org-copy-dialog').style.display='none'">Cancel</button>
1874
- <button class="btn" onclick="v2DoCopyOrg()" style="color:var(--accent);border-color:var(--accent)">Copy</button>
1883
+ <button class="btn" id="org-copy-confirm-btn" onclick="v2DoCopyOrg()" style="color:var(--accent);border-color:var(--accent)">Copy</button>
1875
1884
  </div>
1876
1885
  </div>
1877
1886
  </div>
1878
- <!-- agent detail drawer (node-click / role-card-click) -->
1879
- <div id="org-copy-dialog" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,0.55);z-index:90;align-items:center;justify-content:center">
1880
- <div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;width:320px;display:flex;flex-direction:column;gap:14px">
1881
- <div style="font-size:13px;font-weight:600;color:var(--text-hi)">Copy org to project</div>
1882
- <input id="org-copy-dest" class="filter-input" placeholder="Destination project path…" style="width:100%">
1883
- <div style="display:flex;gap:8px;justify-content:flex-end">
1884
- <button class="btn" onclick="document.getElementById('org-copy-dialog').style.display='none'">Cancel</button>
1885
- <button class="btn" onclick="v2DoCopyOrg()" style="color:var(--accent);border-color:var(--accent)">Copy</button>
1886
- </div>
1887
- </div>
1888
- </div>
1887
+ <div id="org-import-input-wrap" style="display:none"><input type="file" id="org-import-file" accept=".json" style="display:none"></div>
1889
1888
  <div id="org-agent-drawer" aria-hidden="true">
1890
1889
  <div id="oad-head"></div>
1891
1890
  <div id="oad-body"></div>
@@ -2124,9 +2123,13 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
2124
2123
  <select id="chat-v-sel" onchange="chatVSelectSession(this.value)">
2125
2124
  <option value="">— select a session —</option>
2126
2125
  </select>
2126
+ <select id="chat-v-org-run-sel" style="display:none" onchange="chatVSelectOrgRun(this.value)" title="Select org run to replay communications">
2127
+ <option value="">— org run history —</option>
2128
+ </select>
2127
2129
  <div id="chat-v-live-dot"></div>
2128
2130
  <span id="chat-v-live-lbl">OFFLINE</span>
2129
2131
  </div>
2132
+ <div id="chat-v-excerpt" class="cv-excerpt-banner"></div>
2130
2133
  <div id="chat-v-feed">
2131
2134
  <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>
2132
2135
  </div>
@@ -2265,7 +2268,7 @@ document.getElementById('feed-scroll').addEventListener('scroll', () => {
2265
2268
  userScrolled = document.getElementById('feed-scroll').scrollTop > 50;
2266
2269
  });
2267
2270
 
2268
- function switchView(v) {
2271
+ function switchView(v, { updateHash = true } = {}) {
2269
2272
  currentView = v;
2270
2273
  document.querySelectorAll('.nav-item[data-view]').forEach(el =>
2271
2274
  el.classList.toggle('active', el.dataset.view === v));
@@ -2276,12 +2279,55 @@ function switchView(v) {
2276
2279
  const PROJECT = DIR ? shortPath(DIR) : 'monomind';
2277
2280
  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' };
2278
2281
  document.title = `monomind · ${PROJECT} · ${VIEW_LABELS[v] || v}`;
2282
+ if (updateHash) _pushHash();
2279
2283
  // Projects always re-fetches so onclick paths in cards stay current
2280
2284
  if (v === 'projects') { renderProjects(); return; }
2281
2285
  if (v === 'chat') { initChatView(); return; }
2282
2286
  if (!viewRendered[v]) { renderView(v); viewRendered[v] = true; }
2283
2287
  }
2284
2288
 
2289
+ // ── Hash-based routing ────────────────────────────────────────────────────────
2290
+ // Format: #<view> or #orgs/<orgName>/<tab>
2291
+ let _suppressHashChange = false;
2292
+
2293
+ function _pushHash() {
2294
+ let hash = currentView || 'now';
2295
+ if (currentView === 'orgs' && typeof _v2SelOrg !== 'undefined' && _v2SelOrg) {
2296
+ const tab = (typeof _v2OrgTab !== 'undefined' && _v2OrgTab) ? _v2OrgTab : 'chart';
2297
+ hash = 'orgs/' + encodeURIComponent(_v2SelOrg) + '/' + tab;
2298
+ }
2299
+ _suppressHashChange = true;
2300
+ location.hash = '#' + hash;
2301
+ setTimeout(() => { _suppressHashChange = false; }, 50);
2302
+ }
2303
+
2304
+ function _restoreFromHash() {
2305
+ if (_suppressHashChange) return;
2306
+ const raw = location.hash.replace(/^#/, '');
2307
+ if (!raw) { _pushHash(); return; }
2308
+ const parts = raw.split('/');
2309
+ const view = parts[0];
2310
+ if (!view) return;
2311
+ if (view === 'orgs') {
2312
+ switchView('orgs', { updateHash: false });
2313
+ const orgName = parts[1] ? decodeURIComponent(parts[1]) : null;
2314
+ const tab = parts[2] || 'chart';
2315
+ if (orgName) {
2316
+ // _v2Orgs is populated async by renderOrgs(); retry until available
2317
+ const _trySelect = (attempts) => {
2318
+ if (typeof _v2Orgs !== 'undefined' && _v2Orgs && _v2Orgs.length > 0) {
2319
+ v2SelectOrg(orgName).then(() => { if (tab !== 'chart') v2SwitchOrgTab(tab); });
2320
+ } else if (attempts > 0) {
2321
+ setTimeout(() => _trySelect(attempts - 1), 250);
2322
+ }
2323
+ };
2324
+ _trySelect(20);
2325
+ }
2326
+ } else {
2327
+ switchView(view, { updateHash: false });
2328
+ }
2329
+ }
2330
+
2285
2331
  function renderView(v) {
2286
2332
  if (v === 'now') { refreshNow(); return; }
2287
2333
  if (v === 'projects') renderProjects();
@@ -2351,6 +2397,9 @@ async function init() {
2351
2397
  await refreshNow();
2352
2398
  if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
2353
2399
  initSSE();
2400
+ // Restore view from URL hash (after initial data load so orgs list is ready)
2401
+ _restoreFromHash();
2402
+ window.addEventListener('hashchange', () => _restoreFromHash());
2354
2403
  }
2355
2404
 
2356
2405
  function _setLiveMode(mode) {
@@ -3918,42 +3967,190 @@ async function renderGlobalFeed() {
3918
3967
  }
3919
3968
 
3920
3969
  // ── agent chat view ────────────────────────────────────────
3921
- let chatVSessions = {};
3970
+ let chatVSessions = {}; // sessionId or groupId → session/group object
3971
+ let chatVGroupMap = {}; // groupId → group object
3972
+ let chatVSessionGroupMap = {}; // sessionId → groupId (for sessions inside a group)
3922
3973
  let chatVCurrentId = null;
3974
+ let chatVCurrentGroupSessions = new Set(); // session IDs belonging to the currently selected group
3923
3975
  let chatVSseSource = null;
3976
+ let chatVSeenKeys = new Set();
3924
3977
 
3925
3978
  function initChatView() {
3926
3979
  loadChatViewSessions();
3980
+ loadChatViewOrgRuns();
3927
3981
  if (!chatVSseSource) connectChatViewSSE();
3928
3982
  }
3929
3983
 
3984
+ async function loadChatViewOrgRuns() {
3985
+ // Populate the org run history selector with recent org runs
3986
+ const runSel = document.getElementById('chat-v-org-run-sel');
3987
+ if (!runSel) return;
3988
+ try {
3989
+ const dir = DIR ? `?dir=${encodeURIComponent(DIR)}` : '';
3990
+ const orgs = await apiFetch('/api/orgs' + dir).catch(() => []);
3991
+ const orgList = Array.isArray(orgs) ? orgs : (orgs.orgs || []);
3992
+ const runEntries = [];
3993
+ await Promise.all(orgList.slice(0, 10).map(async org => {
3994
+ const name = org.name || org;
3995
+ if (!name) return;
3996
+ try {
3997
+ const runs = await apiFetch(`/api/org/${encodeURIComponent(name)}/runs${dir}`);
3998
+ (Array.isArray(runs) ? runs : (runs.runs || [])).slice(0, 5).forEach(r => {
3999
+ runEntries.push({ org: name, runId: r.runId || r.id, startedAt: r.startedAt || 0 });
4000
+ });
4001
+ } catch (_) {}
4002
+ }));
4003
+ runEntries.sort((a, b) => b.startedAt - a.startedAt);
4004
+ while (runSel.options.length > 1) runSel.remove(1);
4005
+ runEntries.slice(0, 20).forEach(r => {
4006
+ const opt = document.createElement('option');
4007
+ opt.value = JSON.stringify({ org: r.org, runId: r.runId });
4008
+ const ts = r.startedAt ? new Date(r.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4009
+ opt.textContent = r.org + '/' + (r.runId || '').slice(0, 12) + (ts ? ' ' + ts : '');
4010
+ runSel.appendChild(opt);
4011
+ });
4012
+ runSel.style.display = runEntries.length ? '' : 'none';
4013
+ } catch(e) { console.warn('chat org runs load failed', e); }
4014
+ }
4015
+
4016
+ async function chatVSelectOrgRun(value) {
4017
+ if (!value) return;
4018
+ let org, runId;
4019
+ try { ({ org, runId } = JSON.parse(value)); } catch { return; }
4020
+ const feed = document.getElementById('chat-v-feed');
4021
+ const empty = document.getElementById('chat-v-empty');
4022
+ if (!feed) return;
4023
+ feed.innerHTML = '';
4024
+ // Clear session selection — org run replay is a different mode;
4025
+ // also set chatVCurrentId=null so appendChatViewEvent session-guard doesn't filter events
4026
+ chatVCurrentId = null;
4027
+ const sessEl = document.getElementById('chat-v-sel');
4028
+ if (sessEl) sessEl.value = '';
4029
+ try {
4030
+ const dir = DIR ? `?dir=${encodeURIComponent(DIR)}` : '';
4031
+ const events = await apiFetch(`/api/org/${encodeURIComponent(org)}/runs/${encodeURIComponent(runId)}${dir}`);
4032
+ const evArr = Array.isArray(events) ? events : (events.events || []);
4033
+ if (!evArr.length) { feed.appendChild(empty || document.createTextNode('No events in this run.')); return; }
4034
+ evArr.forEach(ev => appendChatViewEvent(ev, false));
4035
+ feed.scrollTop = feed.scrollHeight;
4036
+ } catch(e) {
4037
+ feed.textContent = 'Failed to load run events: ' + e.message;
4038
+ }
4039
+ }
4040
+
3930
4041
  async function loadChatViewSessions() {
3931
4042
  try {
4043
+ // Snapshot SSE-seeded sessions before the async fetch — the API response may not yet
4044
+ // include events that arrived via SSE right before the fetch, so we preserve their
4045
+ // buffered events and merge them back after (same pattern as _odtLoadChatSessions).
4046
+ const _prevChatSessions = { ...chatVSessions };
3932
4047
  const data = await apiFetch('/api/mastermind/sessions');
3933
4048
  chatVSessions = {};
4049
+ chatVGroupMap = {};
4050
+ chatVSessionGroupMap = {};
3934
4051
  const sel = document.getElementById('chat-v-sel');
3935
4052
  const prev = sel.value;
3936
4053
  while (sel.options.length > 1) sel.remove(1);
3937
- const sessions = Object.values(data.sessions || {});
3938
- sessions.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
4054
+ // API returns a plain array (not {sessions:{...}}) — handle both shapes for safety
4055
+ const sessions = Array.isArray(data) ? data : Object.values(data.sessions || data || {});
4056
+
4057
+ // Normalize and merge SSE-seeded events before grouping
3939
4058
  sessions.forEach(s => {
4059
+ if (_prevChatSessions[s.id] && (_prevChatSessions[s.id].events || []).length > (s.events || []).length) {
4060
+ s.events = _prevChatSessions[s.id].events;
4061
+ }
4062
+ if (s.status && s.status !== 'running' && s.status !== 'complete') s.status = 'complete';
4063
+ });
4064
+
4065
+ // Group consecutive sessions with same normalized prompt + gap < 20 min → loop group
4066
+ const _CV_MAX_LOOP_GAP = 20 * 60 * 1000;
4067
+ const _cvNormPrompt = p => (p || '').replace(/^running org:\s*/i, '').toLowerCase().replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 40);
4068
+ const sessAsc = [...sessions].sort((a, b) => (a.startedAt || a.ts || 0) - (b.startedAt || b.ts || 0));
4069
+ const cvGroups = []; // [{id, sessions, events, totalEvents, status, prompt, startedAt, _newestAt, _normKey, _isGroup}]
4070
+ sessAsc.forEach(s => {
4071
+ const key = _cvNormPrompt(s.prompt);
4072
+ const sAt = s.startedAt || s.ts || 0;
4073
+ const last = cvGroups[cvGroups.length - 1];
4074
+ if (last && last._normKey === key && key && (sAt - last._newestAt) < _CV_MAX_LOOP_GAP) {
4075
+ last.sessions.push(s);
4076
+ last._newestAt = sAt;
4077
+ last.totalEvents += (s.events || []).length;
4078
+ if (s.status === 'running') last.status = 'running';
4079
+ (s.events || []).forEach(ev => last.events.push(ev));
4080
+ } else {
4081
+ cvGroups.push({
4082
+ id: s.id,
4083
+ sessions: [s],
4084
+ _normKey: key,
4085
+ _newestAt: sAt,
4086
+ totalEvents: (s.events || []).length,
4087
+ status: s.status || 'complete',
4088
+ prompt: s.prompt,
4089
+ startedAt: sAt,
4090
+ domains: s.domains || [],
4091
+ events: [...(s.events || [])],
4092
+ _isGroup: false
4093
+ });
4094
+ }
4095
+ });
4096
+ // Finalize groups
4097
+ cvGroups.forEach(g => {
4098
+ if (g.sessions.length > 1) {
4099
+ g._isGroup = true;
4100
+ g.id = 'grp:' + g.sessions[0].id;
4101
+ g.events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
4102
+ }
4103
+ });
4104
+ // Sort groups newest first, register
4105
+ cvGroups.sort((a, b) => b._newestAt - a._newestAt);
4106
+ cvGroups.forEach(g => {
4107
+ chatVSessions[g.id] = g;
4108
+ chatVGroupMap[g.id] = g;
4109
+ g.sessions.forEach(s => {
4110
+ chatVSessions[s.id] = g; // session ID also routes to group
4111
+ if (g._isGroup) chatVSessionGroupMap[s.id] = g.id;
4112
+ });
3940
4113
  const opt = document.createElement('option');
3941
- opt.value = s.id;
3942
- const ts = s.startedAt ? new Date(s.startedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
3943
- opt.textContent = (s.id.slice(0,16)) + (ts ? ' ' + ts : '') + (s.status === 'running' ? ' ●' : '');
4114
+ opt.value = g.id;
4115
+ const ts = g._newestAt ? new Date(g._newestAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4116
+ const label = (g.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 30) || g.id.slice(0, 16);
4117
+ const evCount = g.totalEvents || (g.events || []).length;
4118
+ const msgPart = evCount ? ' ' + evCount + 'ev' : '';
4119
+ const repPart = g._isGroup ? ' ' + g.sessions.length + 'reps' : '';
4120
+ opt.textContent = label + (ts ? ' ' + ts : '') + repPart + msgPart + (g.status === 'running' ? ' ●' : '');
3944
4121
  sel.appendChild(opt);
3945
- chatVSessions[s.id] = s;
3946
4122
  });
3947
- if (prev && chatVSessions[prev]) { sel.value = prev; }
3948
- else {
3949
- const running = sessions.find(s => s.status === 'running');
3950
- if (running) { sel.value = running.id; chatVSelectSession(running.id); }
4123
+
4124
+ // Re-attach SSE-only sessions not yet in the API response (arrived during fetch)
4125
+ Object.values(_prevChatSessions).forEach(ps => {
4126
+ if (!chatVSessions[ps.id]) {
4127
+ chatVSessions[ps.id] = ps;
4128
+ const opt = document.createElement('option');
4129
+ opt.value = ps.id;
4130
+ const tsVal = ps.startedAt || 0;
4131
+ const ts = tsVal ? new Date(tsVal).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
4132
+ const label = (ps.prompt || '').replace(/^running org:\s*/i, '').slice(0, 40) || ps.id.slice(0, 16);
4133
+ opt.textContent = label + (ts ? ' ' + ts : '') + ' ●';
4134
+ sel.appendChild(opt);
4135
+ }
4136
+ });
4137
+
4138
+ // Resolve prev selection: may be a sessionId inside a group now
4139
+ const prevResolved = prev ? (chatVSessionGroupMap[prev] || (chatVSessions[prev] ? prev : null)) : null;
4140
+ if (prevResolved && chatVSessions[prevResolved]) {
4141
+ sel.value = prevResolved;
4142
+ if (!chatVCurrentId) chatVSelectSession(prevResolved);
4143
+ } else {
4144
+ const runningGroup = cvGroups.find(g => g.status === 'running');
4145
+ if (runningGroup) { sel.value = runningGroup.id; chatVSelectSession(runningGroup.id); }
4146
+ else if (cvGroups.length) { sel.value = cvGroups[0].id; chatVSelectSession(cvGroups[0].id); }
3951
4147
  }
3952
4148
  } catch(e) { console.warn('chat sessions load failed', e); }
3953
4149
  }
3954
4150
 
3955
4151
  function chatVSelectSession(id) {
3956
4152
  chatVCurrentId = id;
4153
+ chatVCurrentGroupSessions.clear();
3957
4154
  const feed = document.getElementById('chat-v-feed');
3958
4155
  const empty = document.getElementById('chat-v-empty');
3959
4156
  if (!id || !chatVSessions[id]) {
@@ -3963,24 +4160,118 @@ function chatVSelectSession(id) {
3963
4160
  }
3964
4161
  feed.innerHTML = '';
3965
4162
  const session = chatVSessions[id];
4163
+ // Populate group session set so live SSE events from any rep appear in feed
4164
+ if (session._isGroup && session.sessions) {
4165
+ session.sessions.forEach(s => chatVCurrentGroupSessions.add(s.id));
4166
+ }
4167
+ // Inject session metadata header
4168
+ const tsVal = session.startedAt || session.ts || 0;
4169
+ const tsStr = tsVal ? new Date(tsVal).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
4170
+ const domains = (session.domains || []);
4171
+ const agents = domains.length ? domains.join(', ') : '—';
4172
+ const evCount = session._isGroup ? (session.totalEvents || (session.events || []).length) : (session.events || []).length;
4173
+ const agentNames = (() => {
4174
+ const names = new Set();
4175
+ (session.events || []).forEach(ev => {
4176
+ if (ev.agent) names.add(ev.agent);
4177
+ if (ev.from && ev.from !== 'system') names.add(ev.from);
4178
+ });
4179
+ return names.size ? [...names].join(', ') : agents;
4180
+ })();
4181
+ const prompt = (session.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 120) || id;
4182
+ const repInfo = session._isGroup ? ' · ' + session.sessions.length + ' reps' : '';
4183
+ const statusCls = session.status === 'running' ? 'color:oklch(65% 0.20 145)' : session.status === 'complete' ? 'color:var(--text-lo)' : 'color:oklch(65% 0.20 25)';
4184
+ // Compute cost + duration from events
4185
+ const _allEvs = session.events || [];
4186
+ const _totalCost = _allEvs.reduce((acc, ev) => acc + (Number(ev.cost_usd) || 0), 0);
4187
+ const _costStr = _totalCost > 0 ? '$' + _totalCost.toFixed(4) : '';
4188
+ const _startTs = session.startedAt || session.ts || 0;
4189
+ const _endTs = session.endedAt || (_allEvs[_allEvs.length-1]?.ts) || 0;
4190
+ const _durMs = _endTs > _startTs ? _endTs - _startTs : 0;
4191
+ const _durStr = _durMs > 60000 ? Math.round(_durMs/60000) + 'm' : _durMs > 0 ? Math.round(_durMs/1000) + 's' : '';
4192
+ const hdr = document.createElement('div');
4193
+ hdr.className = 'cv-session-hdr';
4194
+ hdr.innerHTML = `<div class="cv-shdr-prompt">${esc(prompt)}${repInfo ? '<span style="color:var(--text-lo);font-size:10px"> ' + esc(repInfo) + '</span>' : ''}</div>`+
4195
+ `<div class="cv-shdr-meta">`+
4196
+ `<span class="cv-shdr-id" title="${esc(id)}">${esc(id)}</span>`+
4197
+ `<span class="cv-shdr-sep">·</span>`+
4198
+ `<span style="${statusCls}">${esc(session.status || 'unknown')}</span>`+
4199
+ `<span class="cv-shdr-sep">·</span>`+
4200
+ `<span title="started">${esc(tsStr)}</span>`+
4201
+ (_durStr ? `<span class="cv-shdr-sep">·</span><span title="duration">${esc(_durStr)}</span>` : '')+
4202
+ `<span class="cv-shdr-sep">·</span>`+
4203
+ `<span title="agents">${esc(agentNames)}</span>`+
4204
+ `<span class="cv-shdr-sep">·</span>`+
4205
+ `<span title="event count">${evCount} events</span>`+
4206
+ (_costStr ? `<span class="cv-shdr-sep">·</span><span style="color:oklch(65% 0.18 50)" title="total cost">${esc(_costStr)}</span>` : '')+
4207
+ `</div>`;
4208
+ feed.appendChild(hdr);
3966
4209
  const events = session.events || [];
3967
- events.forEach(ev => appendChatViewEvent(ev, false));
4210
+ events.forEach(ev => {
4211
+ appendChatViewEvent(ev, false);
4212
+ // Pre-seed dedup set so SSE reconnect replays of historical events are swallowed
4213
+ 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);
4214
+ chatVSeenKeys.add(_cvhk);
4215
+ });
4216
+ // Populate excerpt banner with last activity for the selected session
4217
+ const _cvExEl = document.getElementById('chat-v-excerpt');
4218
+ if (_cvExEl) { if (events.length) renderCvExcerptBanner('chat-v-excerpt', events, session); else _cvExEl.innerHTML = ''; }
3968
4219
  feed.scrollTop = feed.scrollHeight;
3969
4220
  }
3970
4221
 
3971
4222
  function appendChatViewEvent(ev, animate) {
3972
- if (chatVCurrentId && ev.session && ev.session !== chatVCurrentId) return;
4223
+ if (chatVCurrentId && ev.session && ev.session !== chatVCurrentId && !chatVCurrentGroupSessions.has(ev.session)) return;
3973
4224
  const feed = document.getElementById('chat-v-feed');
3974
4225
  if (!feed) return;
3975
4226
  const empty = document.getElementById('chat-v-empty');
3976
4227
  if (empty && feed.contains(empty)) feed.removeChild(empty);
3977
4228
 
3978
4229
  let el;
4230
+ const atBottom = !animate || (feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80);
3979
4231
  const ts = ev.ts ? new Date(ev.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
3980
- if (ev.type === 'intercom') {
4232
+ if (ev.type === 'run:start') {
4233
+ el = mkCVSys('▶ Run started' + (ev.goal ? ': ' + esc(ev.goal.slice(0,80)) : '') + (ev.bossRole ? ' · boss: ' + esc(ev.bossRole) : ''), ts);
4234
+ } else if (ev.type === 'run:cycle:complete') {
4235
+ el = mkCVSys('◎ Cycle complete' + (ev.pending != null ? ' · ' + esc(String(ev.pending)) + ' tasks pending' : ''), ts);
4236
+ } else if (ev.type === 'intercom') {
3981
4237
  el = mkCVIntercom(ev.from, ev.to, ev.msg || '', ts);
3982
- } else if (ev.type === 'agent:message' || ev.type === 'agent:spawn') {
3983
- el = mkCVAgent(ev.agent || ev.name || '?', ev.msg || ev.message || ev.role || ev.type, ts, ev.type);
4238
+ } else if (ev.type === 'org:comms') {
4239
+ const _cvCommsFrom = ev.from || ev.role || ev.org || '?';
4240
+ // Directed messages (to set, not broadcast) → intercom bubble; broadcasts → agent bubble
4241
+ if (ev.to && ev.to !== 'all') el = mkCVIntercom(_cvCommsFrom, ev.to, ev.msg || '', ts);
4242
+ else el = mkCVAgent(_cvCommsFrom, ev.msg || '', ts, 'org:comms');
4243
+ } else if (ev.type === 'org:agent:online') {
4244
+ el = mkCVSys('[' + esc(ev.org || '') + '] ' + esc(ev.role || ev.title || '?') + ' online' + (ev.title && ev.role && ev.title !== ev.role ? ' (' + esc(ev.title) + ')' : ''), ts);
4245
+ } else if (ev.type === 'org:checkpoint') {
4246
+ el = mkCVSys('[checkpoint] ' + esc(ev.summary || ev.progress || ev.msg || ''), ts);
4247
+ } else if (ev.type === 'org:start') {
4248
+ el = mkCVSys('[org:start] ' + esc(ev.org || ''), ts);
4249
+ } else if (ev.type === 'run:complete') {
4250
+ el = mkCVSys('■ Run complete' + (ev.status ? ' [' + esc(ev.status) + ']' : ''), ts);
4251
+ } else if (ev.type === 'org:complete') {
4252
+ el = mkCVSys('■ Org complete' + (ev.org ? ' — ' + esc(ev.org) : ''), ts);
4253
+ } else if (ev.type === 'agent:result') {
4254
+ el = mkCVResult(ev.agent || ev.from || '?', ev.result || ev.msg || ev.message || ev.summary || '', ts);
4255
+ } else if (ev.type === 'agent:spawn') {
4256
+ const _spawnTo = ev.to || ev.agentType || '?';
4257
+ const _spawnTask = ev.task || ev.briefing || '';
4258
+ el = mkCVSpawn(ev.from || 'orchestrator', _spawnTo, _spawnTask, ts);
4259
+ } else if (ev.type === 'agent:complete') {
4260
+ // Rich completion event from capture-handler (replaces/supplements org:comms)
4261
+ const _acAgent = ev.agentType || ev.role || ev.from || '?';
4262
+ const _acResult = ev.result || '';
4263
+ const _acCost = ev.cost_usd != null ? ' $' + Number(ev.cost_usd).toFixed(4) : '';
4264
+ const _acTok = (ev.tokens_in || ev.tokens_out) ? ' · ' + ((ev.tokens_in||0)+(ev.tokens_out||0)).toLocaleString() + ' tok' : '';
4265
+ const _acTools = (ev.toolCalls || []).length ? ' · [' + ev.toolCalls.slice(0,5).map(t => esc(t)).join(', ') + ']' : '';
4266
+ el = mkCVResult(_acAgent + _acCost + _acTok + _acTools, _acResult, ts);
4267
+ } else if (ev.type === 'agent:usage') {
4268
+ // Token/cost accounting event — show as compact system line
4269
+ const _auAgent = ev.role || ev.agentType || '?';
4270
+ const _auCost = ev.cost_usd != null ? '$' + Number(ev.cost_usd).toFixed(4) : '';
4271
+ const _auTok = (ev.tokens_in || ev.tokens_out) ? ((ev.tokens_in||0)).toLocaleString() + '↑ ' + ((ev.tokens_out||0)).toLocaleString() + '↓' : '';
4272
+ el = mkCVSys('◈ ' + esc(_auAgent) + (_auTok ? ' · ' + _auTok : '') + (_auCost ? ' · ' + _auCost : ''), ts);
4273
+ } else if (ev.type === 'agent:message') {
4274
+ el = mkCVAgent(ev.agent || ev.name || '?', ev.msg || ev.message || '', ts, ev.type);
3984
4275
  } else if (ev.type === 'session:start') {
3985
4276
  el = mkCVSys('Session started' + (ev.prompt ? ': ' + esc(ev.prompt.slice(0,80)) : ''), ts);
3986
4277
  } else if (ev.type === 'session:complete') {
@@ -3990,38 +4281,64 @@ function appendChatViewEvent(ev, animate) {
3990
4281
  } else if (ev.type === 'domain:complete') {
3991
4282
  el = mkCVSys('✓ ' + esc(ev.domain || '') + (ev.status ? ' [' + esc(ev.status) + ']' : ''), ts);
3992
4283
  } else if (ev.type === 'loop:start') {
3993
- el = mkCVSys('Loop started: ' + esc(ev.command || ''), ts);
4284
+ el = mkCVSys('Loop: ' + esc(ev.command || '') + (ev.repeat ? ' ×' + esc(String(ev.repeat)) : ''), ts);
3994
4285
  if (currentView === 'loops') renderLoops();
3995
4286
  } else if (ev.type === 'loop:complete') {
3996
- el = mkCVSys('Loop complete: ' + esc(ev.command || '') + (ev.ranReps ? ' (' + ev.ranReps + ' runs)' : ''), ts);
4287
+ el = mkCVSys('Loop done: ' + esc(ev.command || '') + (ev.ranReps ? ' (' + esc(String(ev.ranReps)) + ' runs)' : ''), ts);
3997
4288
  if (currentView === 'loops') renderLoops();
3998
4289
  } else if (ev.type === 'loop:tick') {
3999
- el = mkCVSys('Loop tick: ' + esc(ev.command || ev.id || ''), ts);
4290
+ const _tickLbl = ev.rep != null
4291
+ ? 'rep ' + esc(String(ev.rep)) + ' done' + (ev.wait != null ? ' → next in ' + esc(String(ev.wait)) + 's' : '')
4292
+ : esc(ev.command || ev.loopId || ev.id || '');
4293
+ el = mkCVSys('◷ ' + _tickLbl, ts);
4294
+ el.dataset.evType = 'loop:tick';
4295
+ if (feed.lastElementChild?.dataset?.evType === 'loop:tick') feed.lastElementChild.remove();
4000
4296
  if (currentView === 'loops') renderLoops();
4001
- } else if (ev.type === 'loop:hil') {
4002
- el = mkCVSys('⚠ Loop HIL: ' + esc(ev.command || ev.id || ''), ts);
4297
+ } else if (ev.type === 'loop:hil' || ev.type === 'loop:hil:waiting') {
4298
+ el = mkCVSys('⚠ Loop HIL: ' + esc(ev.command || ev.loopId || ev.id || ''), ts);
4003
4299
  if (currentView === 'loops') renderLoops();
4300
+ } else if (ev.type === 'loop:hil:resolved') {
4301
+ el = mkCVSys('✓ Loop HIL resolved: ' + esc(ev.loopId || ev.command || ''), ts);
4302
+ if (currentView === 'loops') renderLoops();
4303
+ } else if (ev.type === 'file:write') {
4304
+ el = mkCVFileCard(ev.name || ev.path || '?', ev.path || '', ev.size || 0, ts);
4305
+ } else if (ev.type === 'org:agent:offline') {
4306
+ el = mkCVSys('◌ ' + esc(ev.title || ev.role || '?') + ' offline' + (ev.org ? ' [' + esc(ev.org) + ']' : ''), ts);
4307
+ } else if (ev.type === 'org:error') {
4308
+ el = mkCVSys('⚠ Error: ' + esc(ev.msg || ev.error || ev.message || ''), ts);
4309
+ el.classList.add('cv-err');
4004
4310
  } else {
4005
4311
  el = mkCVSys(esc(ev.type || 'event'), ts);
4006
4312
  }
4007
4313
  if (animate) el.classList.add('cv-new');
4008
4314
  feed.appendChild(el);
4009
- feed.scrollTop = feed.scrollHeight;
4315
+ if (atBottom) feed.scrollTop = feed.scrollHeight;
4010
4316
  }
4011
4317
 
4012
4318
  function _cvExcerptText(ev) {
4013
4319
  const t = ev.type || '';
4014
- if (t === 'intercom' || t === 'org:comms') return (ev.from || '?') + ' → ' + (ev.to || '?') + ': ' + (ev.msg || ev.message || '').slice(0, 80);
4320
+ if (t === 'intercom' || t === 'org:comms') return (ev.from || ev.role || '?') + ' → ' + (ev.to || '?') + ': ' + (ev.msg || ev.message || '').slice(0, 80);
4015
4321
  if (t === 'agent:message') return (ev.agent || ev.name || '?') + ': ' + (ev.msg || ev.message || '').slice(0, 80);
4016
- if (t === 'agent:spawn' || t === 'org:agent:online') return 'spawned ' + (ev.agent || ev.role || ev.title || '?');
4017
- if (t === 'org:checkpoint') return 'checkpoint: ' + (ev.progress || '').slice(0, 80) + (ev.pending_tasks != null ? ' · ' + ev.pending_tasks + ' pending' : '');
4322
+ if (t === 'agent:result') return (ev.agent || ev.from || '?') + ': ' + (ev.result || ev.msg || ev.message || ev.summary || '').slice(0, 80);
4323
+ if (t === 'agent:spawn') return 'spawned ' + (ev.to || ev.agent || ev.role || '?') + (ev.task || ev.briefing ? ': ' + (ev.task || ev.briefing || '').slice(0, 50) : '');
4324
+ if (t === 'org:agent:online') return (ev.title || ev.role || '?') + ' online';
4325
+ if (t === 'org:agent:offline') return (ev.title || ev.role || '?') + ' offline';
4326
+ if (t === 'org:error') return 'error: ' + (ev.msg || ev.error || ev.message || '').slice(0, 80);
4327
+ if (t === 'file:write') return (ev.name || ev.path || '?') + (ev.size ? ' (' + (ev.size > 1048576 ? (ev.size/1048576).toFixed(1)+'MB' : ev.size > 1024 ? (ev.size/1024).toFixed(1)+'KB' : ev.size+'B') + ')' : '');
4328
+ if (t === 'org:checkpoint') return 'checkpoint: ' + (ev.summary || ev.progress || ev.msg || '').slice(0, 80) + (ev.pending_tasks != null ? ' · ' + ev.pending_tasks + ' pending' : '');
4329
+ if (t === 'run:complete') return 'run complete' + (ev.status ? ' [' + ev.status + ']' : '');
4330
+ if (t === 'run:cycle:complete') return 'cycle done' + (ev.pending != null ? ' · ' + ev.pending + ' pending' : '');
4018
4331
  if (t === 'org:start') return 'org started: ' + (ev.goal || '').slice(0, 80);
4019
4332
  if (t === 'org:complete') return 'org complete';
4020
4333
  if (t === 'session:start') return 'started: ' + (ev.prompt || '').replace(/^running org:\s*/i, '').slice(0, 80);
4021
4334
  if (t === 'session:complete') return 'completed' + (ev.status ? ' [' + ev.status + ']' : '');
4022
4335
  if (t === 'domain:dispatch') return '→ ' + (ev.domain || '') + (ev.cmd ? ': ' + ev.cmd.slice(0, 60) : '');
4023
4336
  if (t === 'domain:complete') return '✓ ' + (ev.domain || '');
4024
- if (ev.msg) return (ev.msg || '').slice(0, 80);
4337
+ if (t === 'loop:tick') { const _r = ev.rep ?? ev.fromRep; return _r != null ? 'rep ' + _r + ' done' : (ev.command || ev.loopId || t); }
4338
+ if (t === 'loop:start') return 'loop: ' + (ev.command || '');
4339
+ if (t === 'loop:complete') return 'loop done: ' + (ev.command || '') + (ev.ranReps ? ' (' + ev.ranReps + ' runs)' : '');
4340
+ const _content = ev.result || ev.msg || ev.message || ev.error || ev.summary || '';
4341
+ if (_content) return _content.slice(0, 80);
4025
4342
  return t.slice(0, 60);
4026
4343
  }
4027
4344
 
@@ -4029,18 +4346,25 @@ function _cvExcerptTag(ev) {
4029
4346
  const t = ev.type || '';
4030
4347
  if (t === 'intercom' || t === 'org:comms') return 'IC';
4031
4348
  if (t === 'agent:message') return 'MSG';
4349
+ if (t === 'agent:result') return 'RESULT';
4032
4350
  if (t === 'agent:spawn' || t === 'org:agent:online') return 'NEW';
4351
+ if (t === 'org:agent:offline') return 'OFF';
4352
+ if (t === 'org:error') return 'ERR';
4033
4353
  if (t === 'org:checkpoint') return 'CHK';
4354
+ if (t === 'file:write') return 'FILE';
4034
4355
  if (t.startsWith('org:')) return 'ORG';
4356
+ if (t.startsWith('run:')) return 'RUN';
4035
4357
  if (t.startsWith('session:')) return 'SES';
4036
4358
  if (t.startsWith('domain:')) return 'DOM';
4359
+ if (t.startsWith('loop:')) return 'LOOP';
4360
+ if (t.startsWith('agent:')) return 'AGT';
4037
4361
  return 'EVT';
4038
4362
  }
4039
4363
 
4040
4364
  function renderCvExcerptBanner(bannerId, events, sessionMeta) {
4041
4365
  const banner = document.getElementById(bannerId);
4042
4366
  if (!banner) return;
4043
- const SKIP = new Set(['session:start']);
4367
+ const SKIP = new Set(['session:start', 'run:start', 'org:start']);
4044
4368
  const meaningful = (events || []).filter(ev => !SKIP.has(ev.type)).slice(-5).reverse();
4045
4369
  if (!meaningful.length) {
4046
4370
  const prompt = sessionMeta && (sessionMeta.prompt || '');
@@ -4083,7 +4407,7 @@ function mkCVResult(agent, text, ts) {
4083
4407
  `<span class="cv-tag" style="background:oklch(20% 0.10 150);color:oklch(72% 0.20 150)">${esc(agent)}</span>`+
4084
4408
  `<span class="cv-etype" style="color:oklch(68% 0.20 150)">REPORT</span>`+
4085
4409
  `<span class="cv-text" id="${uid}" style="white-space:pre-wrap;line-height:1.6">${esc(preview)}</span>`+
4086
- (isLong ? `<button class="btn" style="font-size:9px;padding:1px 6px;margin-left:6px;flex-shrink:0" onclick="(function(b,id,full){const el=document.getElementById(id);const show=el.dataset.expanded;el.textContent=show?full.slice(0,300)+'…':full;el.dataset.expanded=show?'':'1';b.textContent=show?'Expand':'Collapse';})(this,'${uid}',${JSON.stringify(text)})">Expand</button>` : '')+
4410
+ (isLong ? `<button class="btn" style="font-size:9px;padding:1px 6px;margin-left:6px;flex-shrink:0" data-uid="${uid}" data-full="${esc(text)}" onclick="(function(b){const el=document.getElementById(b.dataset.uid);const full=b.dataset.full;const show=el.dataset.expanded;el.textContent=show?full.slice(0,300)+'…':full;el.dataset.expanded=show?'':'1';b.textContent=show?'Expand':'Collapse';})(this)">Expand</button>` : '')+
4087
4411
  `<span class="cv-ts">${ts}</span></div>`;
4088
4412
  return d;
4089
4413
  }
@@ -4098,7 +4422,7 @@ function mkCVFileCard(name, filePath, size, ts) {
4098
4422
  `<span class="cv-tag" style="background:oklch(22% 0.10 50);color:oklch(78% 0.18 50)">FILE</span>`+
4099
4423
  `<span class="cv-text" style="font-family:var(--mono);font-size:10px">${esc(name)}</span>`+
4100
4424
  `<span style="font-size:9px;color:var(--text-lo);flex-shrink:0">${sizeStr}</span>`+
4101
- (filePath ? `<button class="btn" style="font-size:9px;padding:1px 6px;flex-shrink:0;border-color:oklch(65% 0.18 50);color:oklch(65% 0.18 50)" onclick="v2ViewOrgFile(${JSON.stringify(filePath)},${JSON.stringify(name)})">View</button>` : '')+
4425
+ (filePath ? `<button class="btn" style="font-size:9px;padding:1px 6px;flex-shrink:0;border-color:oklch(65% 0.18 50);color:oklch(65% 0.18 50)" data-fp="${esc(filePath)}" data-fn="${esc(name)}" onclick="v2ViewOrgFile(this.dataset.fp,this.dataset.fn)">View</button>` : '')+
4102
4426
  `<span class="cv-ts">${ts}</span></div>`;
4103
4427
  return d;
4104
4428
  }
@@ -4114,14 +4438,14 @@ function mkCVAgent(name, text, ts, typeTag) {
4114
4438
  const d = document.createElement('div');
4115
4439
  d.className = 'cv-msg cv-agent';
4116
4440
  const tag = typeTag === 'agent:spawn' ? 'SPAWN' : 'MSG';
4117
- d.innerHTML = `<div class="cv-bub"><span class="cv-tag">${esc(name)}</span><span class="cv-etype">${tag}</span><span class="cv-text">${esc(String(text).slice(0,200))}</span><span class="cv-ts">${ts}</span></div>`;
4441
+ d.innerHTML = `<div class="cv-bub"><span class="cv-tag">${esc(name)}</span><span class="cv-etype">${tag}</span><span class="cv-text">${esc(String(text).slice(0,800))}</span><span class="cv-ts">${ts}</span></div>`;
4118
4442
  return d;
4119
4443
  }
4120
4444
 
4121
4445
  function mkCVIntercom(from, to, text, ts) {
4122
4446
  const d = document.createElement('div');
4123
4447
  d.className = 'cv-msg cv-ic';
4124
- d.innerHTML = `<div class="cv-bub"><span class="cv-tag cv-sender">${esc(from||'?')}</span><span class="cv-arrow">→</span><span class="cv-tag cv-receiver">${esc(to||'?')}</span><span class="cv-etype">IC</span><span class="cv-text">${esc(String(text).slice(0,200))}</span><span class="cv-ts">${ts}</span></div>`;
4448
+ d.innerHTML = `<div class="cv-bub"><span class="cv-tag cv-sender">${esc(from||'?')}</span><span class="cv-arrow">→</span><span class="cv-tag cv-receiver">${esc(to||'?')}</span><span class="cv-etype">IC</span><span class="cv-text">${esc(String(text).slice(0,800))}</span><span class="cv-ts">${ts}</span></div>`;
4125
4449
  return d;
4126
4450
  }
4127
4451
 
@@ -4137,24 +4461,69 @@ function connectChatViewSSE() {
4137
4461
  chatVSseSource.onerror = () => {
4138
4462
  dot && dot.classList.remove('on');
4139
4463
  lbl && (lbl.textContent = 'OFFLINE');
4140
- chatVSseSource.close();
4464
+ const _cvSrc = chatVSseSource;
4141
4465
  chatVSseSource = null;
4466
+ if (_cvSrc) _cvSrc.close();
4142
4467
  setTimeout(connectChatViewSSE, 5000);
4143
4468
  };
4144
4469
  }
4145
4470
 
4146
4471
  function handleChatViewEvent(ev) {
4147
- if (!ev || !ev.session) return;
4148
- if (!chatVSessions[ev.session]) {
4149
- chatVSessions[ev.session] = { id: ev.session, events: [], startedAt: ev.ts, status: 'running' };
4150
- loadChatViewSessions();
4151
- } else {
4152
- const s = chatVSessions[ev.session];
4153
- s.events = s.events || [];
4154
- s.events.push(ev);
4155
- if (ev.type === 'session:complete') s.status = 'complete';
4472
+ if (!ev) return;
4473
+ // Deduplicate SSE reconnect replays (server replays last 50 events on every reconnect)
4474
+ 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);
4475
+ if (chatVSeenKeys.has(dedupKey)) return;
4476
+ chatVSeenKeys.add(dedupKey);
4477
+ if (chatVSeenKeys.size > 2000) { const _a = [...chatVSeenKeys]; chatVSeenKeys = new Set(_a.slice(1000)); }
4478
+ // Org events (org:comms, org:agent:online, org:checkpoint, etc.) may carry a session field
4479
+ // from the org run's session ID — route them directly to the feed if the session matches,
4480
+ // or append them unconditionally when the chat view is showing the "live" placeholder.
4481
+ const isOrgEvent = ev.type && (ev.type.startsWith('org:') || ev.type.startsWith('run:') || ev.type.startsWith('loop:'));
4482
+ if (!ev.session && !isOrgEvent) return;
4483
+ if (ev.session) {
4484
+ const existingEntry = chatVSessions[ev.session]; // may be a group object
4485
+ if (!existingEntry) {
4486
+ // Seed events with the triggering event so it isn't lost if loadChatViewSessions
4487
+ // runs before the event is flushed to disk (disk write and SSE broadcast race).
4488
+ chatVSessions[ev.session] = { id: ev.session, events: [ev], startedAt: ev.ts, status: 'running', prompt: ev.prompt || '' };
4489
+ loadChatViewSessions();
4490
+ // Auto-switch to new session on session:start — after loadChatViewSessions it may be grouped
4491
+ if (ev.type === 'session:start') {
4492
+ const _cvGroupId = chatVSessionGroupMap[ev.session] || ev.session;
4493
+ const _cvSel = document.getElementById('chat-v-sel');
4494
+ if (_cvSel) { _cvSel.value = _cvGroupId; chatVSelectSession(_cvGroupId); }
4495
+ }
4496
+ } else {
4497
+ // existingEntry may be a group — push event to it and update status
4498
+ const target = existingEntry;
4499
+ target.events = target.events || [];
4500
+ target.events.push(ev);
4501
+ if (ev.type === 'session:complete') {
4502
+ // Mark this individual session complete; group stays running until all sessions done
4503
+ if (!target._isGroup) target.status = 'complete';
4504
+ else {
4505
+ // Update the underlying session in group
4506
+ const sub = (target.sessions || []).find(s => s.id === ev.session);
4507
+ if (sub) sub.status = 'complete';
4508
+ // Group stays running if any session is still running
4509
+ if (!(target.sessions || []).some(s => s.status === 'running')) target.status = 'complete';
4510
+ }
4511
+ }
4512
+ if (target._isGroup) target.totalEvents = (target.totalEvents || 0) + 1;
4513
+ }
4514
+ // Show event in feed if this session (or its group) is currently selected
4515
+ const _cvGroupId = chatVSessionGroupMap[ev.session];
4516
+ const _cvIsSelected = chatVCurrentId === ev.session || (_cvGroupId && chatVCurrentId === _cvGroupId) || chatVCurrentGroupSessions.has(ev.session);
4517
+ if (_cvIsSelected) {
4518
+ appendChatViewEvent(ev, true);
4519
+ const _cvSess = chatVSessions[_cvGroupId || ev.session];
4520
+ if (_cvSess) renderCvExcerptBanner('chat-v-excerpt', _cvSess.events || [], _cvSess);
4521
+ }
4522
+ } else if (isOrgEvent) {
4523
+ // Org events without a Claude session ID — append to feed regardless of active view;
4524
+ // #chat-v-feed is in the DOM even when hidden, so events accumulate correctly.
4525
+ appendChatViewEvent(ev, true);
4156
4526
  }
4157
- if (chatVCurrentId === ev.session) appendChatViewEvent(ev, true);
4158
4527
  }
4159
4528
 
4160
4529
  // ── feature 4: budget cap + desktop notification ───────────
@@ -4803,7 +5172,7 @@ async function renderLoops() {
4803
5172
  el.innerHTML = loops.map((l, idx) => {
4804
5173
  const maxReps = l.maxReps || 0;
4805
5174
  const curRep = l.currentRep || 0;
4806
- const isTillend = !maxReps || l.loopType === 'tillend' || String(l.command || '').includes('--tillend');
5175
+ const isTillend = !maxReps || l.type === 'tillend' || String(l.command || '').includes('--tillend');
4807
5176
  const nextAt = l.nextRunAt ? parseInt(l.nextRunAt, 10) : 0;
4808
5177
  const isExplicitlyActive = l.status === 'running' || l.status === 'waiting' || l.status === 'active';
4809
5178
  const isOverdue = !isExplicitlyActive && nextAt > 0 && nextAt <= Date.now();
@@ -4822,7 +5191,7 @@ async function renderLoops() {
4822
5191
  const runs = curRep != null ? curRep : '—';
4823
5192
  const pct = (maxReps > 0) ? Math.min(100, Math.round(curRep / maxReps * 100)) : 0;
4824
5193
  const progBar = isTillend
4825
- ? `<div class="lp-bar" title="tillend loop"><div class="lp-fill lp-fill-inf" style="width:100%;opacity:0.3;background:linear-gradient(90deg,var(--accent),transparent)"></div><span style="position:absolute;left:6px;top:0;font-size:9px;color:var(--text-lo)">run ${curRep} / ∞${l.capReps ? ' (cap: '+l.capReps+')' : ''}</span></div>`
5194
+ ? `<div class="lp-bar" title="tillend loop"><div class="lp-fill lp-fill-inf" style="width:100%;opacity:0.3;background:linear-gradient(90deg,var(--accent),transparent)"></div><span style="position:absolute;left:6px;top:0;font-size:9px;color:var(--text-lo)">run ${curRep} / ∞${maxReps ? ' (cap: '+maxReps+')' : ''}</span></div>`
4826
5195
  : (maxReps > 0 && running)
4827
5196
  ? `<div class="lp-bar"><div class="lp-fill" style="width:${pct}%"></div></div>`
4828
5197
  : '';
@@ -4852,8 +5221,8 @@ async function renderLoops() {
4852
5221
  ${stopBtn}
4853
5222
  </div>
4854
5223
  <div class="loop-expand">
4855
- ${fullPrompt ? `<div class="le-row"><div class="le-lbl">Prompt</div><div class="le-val mono" style="cursor:pointer" onclick="navigator.clipboard.writeText(${JSON.stringify(fullPrompt)}).then(()=>showToast('Copied','','ok'))">${esc(fullPrompt.slice(0, 300))}</div></div>` : ''}
4856
- ${command && command !== fullPrompt ? `<div class="le-row"><div class="le-lbl">Command</div><div class="le-val mono" style="cursor:pointer" onclick="navigator.clipboard.writeText(${JSON.stringify(command)}).then(()=>showToast('Copied','','ok'))">${esc(command.slice(0, 200))}</div></div>` : ''}
5224
+ ${fullPrompt ? `<div class="le-row"><div class="le-lbl">Prompt</div><div class="le-val mono" style="cursor:pointer" data-cp="${esc(fullPrompt)}" onclick="navigator.clipboard.writeText(this.dataset.cp).then(()=>showToast('Copied','','ok'))">${esc(fullPrompt.slice(0, 300))}</div></div>` : ''}
5225
+ ${command && command !== fullPrompt ? `<div class="le-row"><div class="le-lbl">Command</div><div class="le-val mono" style="cursor:pointer" data-cp="${esc(command)}" onclick="navigator.clipboard.writeText(this.dataset.cp).then(()=>showToast('Copied','','ok'))">${esc(command.slice(0, 200))}</div></div>` : ''}
4857
5226
  <div class="le-row"><div class="le-lbl">Type</div><div class="le-val">${isTillend ? '∞ tillend' : '↺ repeat'}</div></div>
4858
5227
  <div class="le-row"><div class="le-lbl">Interval</div><div class="le-val">${interval}</div></div>
4859
5228
  <div class="le-row"><div class="le-lbl">Status</div><div class="le-val">${isFinished ? '✓ done' : isHil ? '⚠ hil:pending' : running ? '● running' : '○ stopped'}</div></div>
@@ -5230,7 +5599,9 @@ function v2RenderOrgList() {
5230
5599
 
5231
5600
  async function v2SelectOrg(name) {
5232
5601
  _v2SelOrg = name;
5602
+ _pushHash();
5233
5603
  if (typeof v2CloseAgent === 'function') v2CloseAgent();
5604
+ if (_orgLiveInterval) { clearInterval(_orgLiveInterval); _orgLiveInterval = null; }
5234
5605
  const requested = name;
5235
5606
  const requestedDir = DIR;
5236
5607
  document.querySelectorAll('.org-item').forEach(el => {
@@ -5265,11 +5636,14 @@ async function v2SelectOrg(name) {
5265
5636
  _v2OrgData = mainR.config ? { ...mainR, ...mainR.config, running: mainR.running } : mainR;
5266
5637
  _v2OrgData._activity = Array.isArray(actR) ? actR : [];
5267
5638
  _v2OrgData._health = healthR;
5268
- // Fetch supplemental data for new tabs
5269
- const agentsR = await fetch(`/api/org/${_enc}/agents${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
5270
- const budgetsR = await fetch(`/api/org/${_enc}/budgets${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():null).catch(()=>null);
5271
- const membersR = await fetch(`/api/org/${_enc}/members${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
5272
- const issuesR = await fetch(`/api/org/${_enc}/issues${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
5639
+ // Fetch supplemental data for new tabs in parallel
5640
+ const [agentsR, budgetsR, membersR, issuesR] = await Promise.all([
5641
+ fetch(`/api/org/${_enc}/agents${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]),
5642
+ fetch(`/api/org/${_enc}/budgets${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():null).catch(()=>null),
5643
+ fetch(`/api/org/${_enc}/members${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]),
5644
+ fetch(`/api/org/${_enc}/issues${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r=>r.ok?r.json():[]).catch(()=>[]),
5645
+ ]);
5646
+ if (_v2SelOrg !== requested || DIR !== requestedDir) return;
5273
5647
  _v2OrgData._agents = Array.isArray(agentsR) ? agentsR : (agentsR?.agents || []);
5274
5648
  _v2OrgData._budgets = budgetsR;
5275
5649
  _v2OrgData._members = Array.isArray(membersR) ? membersR : (membersR?.members || []);
@@ -5308,6 +5682,7 @@ window.v2SwitchOrgTab = function(tab) {
5308
5682
  });
5309
5683
  document.querySelectorAll('.odt-pane').forEach(p => p.classList.remove('active'));
5310
5684
  document.getElementById('odt-' + tab)?.classList.add('active');
5685
+ _pushHash();
5311
5686
  v2RenderOrgTab(tab);
5312
5687
  };
5313
5688
 
@@ -5353,7 +5728,8 @@ function v2RenderOrgTab(tab) {
5353
5728
 
5354
5729
  // ── org chart ───────────────────────────────
5355
5730
  function _v2OrgIsLeader(r) {
5356
- return r.id === 'boss' || r.type === 'coordinator' || r.type === 'planner';
5731
+ return r.isBoss === true || r.id === 'boss' || r.type === 'coordinator' || r.type === 'planner'
5732
+ || r.agent_type === 'coordinator' || r.agent_type === 'devbot-orchestrator';
5357
5733
  }
5358
5734
 
5359
5735
  function v2RenderOrgChart() {
@@ -5541,22 +5917,27 @@ function v2RenderOrgChart() {
5541
5917
  `<clipPath id="${nodeClipIds[i]}"><circle r="${Math.round(R * 0.52)}"/></clipPath>`
5542
5918
  ).join('');
5543
5919
 
5920
+ // Overlay live running status from in-memory agent SSE state for chart node indicators
5921
+ const _chartRunning = new Set((_v2OrgData?._agents || []).filter(a => a.status === 'running').map(a => a.id));
5544
5922
  let nodesHTML = '';
5545
5923
  roles.forEach((role, i) => {
5546
5924
  const p = pos[role.id];
5547
5925
  if (!p) return;
5548
5926
  const leader = _v2OrgIsLeader(role);
5549
5927
  const color = colorOf(role, i);
5928
+ const isRunning = _chartRunning.has(role.id);
5550
5929
  const displayName = role.name || role.title || role.id;
5551
5930
  const subType = role.type || role.agent_type || '';
5552
5931
  const MAX_LBL = Math.floor(R / 4.2);
5553
5932
  const nameText = displayName.length > MAX_LBL ? displayName.slice(0, MAX_LBL - 1) + '…' : displayName;
5554
5933
  const subTypeText = subType.length > MAX_LBL ? subType.slice(0, MAX_LBL - 1) + '…' : subType;
5555
5934
  const outerRing = leader ? `<circle r="${R + 9}" fill="none" stroke="${color}" stroke-width="0.6" opacity="0.18"/>` : '';
5935
+ 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"/>` : '';
5556
5936
  const nameY = subType ? (R * 0.55 + 2).toFixed(0) : (R * 0.55 + 8).toFixed(0);
5557
5937
  const avR = Math.round(R * 0.52);
5558
5938
  const avatarSrc = v2RoleAvatar(role);
5559
5939
  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)})">
5940
+ ${runningDot}
5560
5941
  <title>${esc(displayName)}${subType ? ` · ${esc(subType)}` : ''} — click for details</title>
5561
5942
  ${outerRing}
5562
5943
  <circle r="${R}" fill="oklch(12% 0.008 55)" stroke="${color}" stroke-width="${leader ? 2.5 : 1.8}" filter="url(#v2glow)"/>
@@ -5624,6 +6005,31 @@ function v2RenderOrgChart() {
5624
6005
  }
5625
6006
 
5626
6007
 
6008
+ // ── in-place running dot updater (avoids full re-render + GSAP flicker on SSE events) ──
6009
+ function v2UpdateChartRunningDots() {
6010
+ if (!_v2OrgData) return;
6011
+ const R = 42;
6012
+ const running = new Set((_v2OrgData._agents || []).filter(a => a.status === 'running').map(a => a.id));
6013
+ document.querySelectorAll('#v2oc-nodes .v2oc-node[data-role]').forEach(g => {
6014
+ const roleId = g.dataset.role;
6015
+ const existing = g.querySelector('.v2-chart-running-dot');
6016
+ if (running.has(roleId) && !existing) {
6017
+ const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
6018
+ c.setAttribute('class', 'v2-chart-running-dot');
6019
+ c.setAttribute('r', '5.5');
6020
+ c.setAttribute('cx', (R * 0.65).toFixed(1));
6021
+ c.setAttribute('cy', (-R * 0.65).toFixed(1));
6022
+ c.setAttribute('fill', 'oklch(68% 0.20 150)');
6023
+ c.setAttribute('stroke', 'oklch(12% 0.008 55)');
6024
+ c.setAttribute('stroke-width', '1.5');
6025
+ c.style.animation = 'blink 2s ease-in-out infinite';
6026
+ g.insertBefore(c, g.firstChild);
6027
+ } else if (!running.has(roleId) && existing) {
6028
+ existing.remove();
6029
+ }
6030
+ });
6031
+ }
6032
+
5627
6033
  // ── avatar lookup ────────────────────────────
5628
6034
  const _v2AvatarKnown = new Set([
5629
6035
  'coder','senior-developer','reviewer','tester','planner','researcher',
@@ -5691,9 +6097,12 @@ function v2RenderOrgRoles() {
5691
6097
  // Determine leader: explicit reports_to=undefined + type=planner/coordinator, or first role, or id=boss
5692
6098
  const leaderIds = new Set(roles.filter(r => r.id === 'boss' || r.type === 'coordinator' || r.type === 'planner').map(r => r.id));
5693
6099
  if (!leaderIds.size) leaderIds.add(roles[0]?.id);
6100
+ // Overlay live running status from in-memory agent SSE state
6101
+ const _rolesRunning = new Set((_v2OrgData?._agents || []).filter(a => a.status === 'running').map(a => a.id));
5694
6102
  pane.innerHTML = `<div class="roles-v2-grid">${roles.map((r, i) => {
5695
6103
  const color = V2_ROLE_COLORS[i % V2_ROLE_COLORS.length];
5696
6104
  const isLeader = leaderIds.has(r.id);
6105
+ const isRunning = _rolesRunning.has(r.id);
5697
6106
  const resps = Array.isArray(r.responsibilities) ? r.responsibilities : [];
5698
6107
  const displayName = r.name || r.title || r.id;
5699
6108
  const roleType = r.type || r.agent_type || '';
@@ -5702,7 +6111,7 @@ function v2RenderOrgRoles() {
5702
6111
  <div class="rv2-head">
5703
6112
  <img class="rv2-avatar" src="${avatarSrc}" alt="${esc(displayName)}" loading="lazy" onerror="this.src='data/avatars/coder.svg'"/>
5704
6113
  <div class="rv2-info">
5705
- <div class="rv2-title">${esc(displayName)}${isLeader ? ' <span style="font-size:10px;color:var(--accent);font-weight:400;opacity:0.8">lead</span>' : ''}</div>
6114
+ <div class="rv2-title">${esc(displayName)}${isLeader ? ' <span style="font-size:10px;color:var(--accent);font-weight:400;opacity:0.8">lead</span>' : ''}${isRunning ? ' <span class="live-dot" style="display:inline-block;vertical-align:middle;margin-left:4px"></span>' : ''}</div>
5706
6115
  ${roleType ? `<span class="rv2-agent">${esc(roleType)}</span>` : ''}
5707
6116
  </div>
5708
6117
  </div>
@@ -5730,16 +6139,18 @@ async function v2OpenAgent(roleId) {
5730
6139
  const bodyEl = document.getElementById('oad-body');
5731
6140
  const headEl = document.getElementById('oad-head');
5732
6141
  if (!drawer || !bodyEl || !headEl) return;
6142
+ const _agentOrg = _v2SelOrg, _agentRole = roleId;
5733
6143
  drawer.classList.add('open'); drawer.setAttribute('aria-hidden', 'false');
5734
6144
  v2HighlightAgentNode(roleId);
5735
6145
  headEl.innerHTML = '<button class="oad-close" onclick="v2CloseAgent()" aria-label="Close">✕</button>';
5736
6146
  bodyEl.innerHTML = '<div class="oad-loading">Loading agent…</div>';
5737
6147
  try {
5738
- const r = await fetch(`/api/org/${encodeURIComponent(_v2SelOrg)}/agent/${encodeURIComponent(roleId)}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`);
6148
+ const r = await fetch(`/api/org/${encodeURIComponent(_agentOrg)}/agent/${encodeURIComponent(_agentRole)}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`);
5739
6149
  if (!r.ok) throw new Error('not found');
6150
+ if (_v2SelOrg !== _agentOrg) return;
5740
6151
  v2RenderAgentDrawer(await r.json());
5741
6152
  } catch (_) {
5742
- bodyEl.innerHTML = '<div class="oad-loading">Could not load agent detail.</div>';
6153
+ if (_v2SelOrg === _agentOrg) bodyEl.innerHTML = '<div class="oad-loading">Could not load agent detail.</div>';
5743
6154
  }
5744
6155
  }
5745
6156
  function v2RenderAgentDrawer(data) {
@@ -5831,16 +6242,38 @@ function v2RenderMarkdown(md) {
5831
6242
  }
5832
6243
 
5833
6244
  // ── org activity ────────────────────────────
6245
+ function fmtOrgEvDetail(ev) {
6246
+ if (ev.type === 'org:comms' && (ev.from || ev.role || ev.to || ev.msg)) {
6247
+ const sender = ev.from || ev.role || '';
6248
+ const route = [sender, ev.to].filter(Boolean).join(' → ');
6249
+ return route ? route + (ev.msg ? ': ' + ev.msg : '') : ev.msg || '';
6250
+ }
6251
+ if (ev.type === 'org:checkpoint') return ev.summary || ev.progress || '';
6252
+ if (ev.type === 'agent:result') return (ev.agent || ev.from || '') + (ev.result || ev.msg || ev.message || ev.summary ? ': ' + (ev.result || ev.msg || ev.message || ev.summary) : '');
6253
+ if (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline') return ev.title || ev.role || ev.agent || ev.id || ev.from || '';
6254
+ if (ev.type === 'org:error') return ev.error || ev.msg || ev.message || '';
6255
+ if (ev.type === 'file:write') return ev.path || ev.file || ev.msg || '';
6256
+ return ev.role||ev.result||ev.msg||ev.message||ev.progress||ev.agent||ev.status||'';
6257
+ }
6258
+
5834
6259
  function v2RenderOrgActivity() {
5835
6260
  if (!_v2SelOrg) return;
5836
6261
  const activity = _v2OrgData?._activity || [];
5837
6262
  const orgEvents = _v2OrgEventLog[_v2SelOrg] || [];
5838
- const events = [...activity, ...orgEvents].sort((a,b) => (b.ts||0)-(a.ts||0)).slice(0,80);
6263
+ const _actSeen = new Set();
6264
+ const events = [...activity, ...orgEvents]
6265
+ .filter(e => { const k=(e.ts||'')+'|'+(e.type||'')+'|'+(e.runId||e.session||'')+'|'+(e.from||e.role||e.agent||'')+'|'+(e.result||e.msg||e.message||e.summary||'').slice(0,20); return _actSeen.has(k)?false:(_actSeen.add(k),true); })
6266
+ .sort((a,b) => (b.ts||0)-(a.ts||0)).slice(0,80);
5839
6267
  const pane = document.getElementById('odt-activity');
5840
6268
  if (!pane) return;
5841
6269
  const fmtOrgEvType = t => {
5842
- const m={'org:start':'start','org:stop':'stop','org:complete':'done','org:create':'create','org:heartbeat':'hb','org:agent:online':'agent-on','org:comms':'comms'};
5843
- return m[t]||(t||'').replace(/^org:/,'');
6270
+ const m={
6271
+ 'org:start':'start','org:stop':'stop','org:complete':'done','org:create':'create',
6272
+ 'org:heartbeat':'hb','org:agent:online':'agent-on','org:comms':'comms',
6273
+ 'org:checkpoint':'checkpoint','run:cycle:complete':'cycle','run:complete':'run-done',
6274
+ 'org:error':'error','org:agent:offline':'agent-off',
6275
+ };
6276
+ return m[t]||(t||'').replace(/^(?:org|run):/,'');
5844
6277
  };
5845
6278
  if (!events.length) {
5846
6279
  pane.innerHTML = '<div class="empty">No activity recorded</div>';
@@ -5848,7 +6281,7 @@ function v2RenderOrgActivity() {
5848
6281
  }
5849
6282
  pane.innerHTML = `<div class="act-v2-list">${events.map(ev => {
5850
6283
  const t = ev.ts ? new Date(ev.ts).toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
5851
- const detail = ev.role||ev.msg||ev.agent||'';
6284
+ const detail = fmtOrgEvDetail(ev) || ev.role || ev.agent || '';
5852
6285
  return `<div class="av2-row"><span class="av2-time">${esc(t)}</span><span class="av2-type">${esc(fmtOrgEvType(ev.type))}</span><span class="av2-msg">${esc(detail)}</span></div>`;
5853
6286
  }).join('')}</div>`;
5854
6287
  }
@@ -5868,7 +6301,11 @@ function v2RenderOrgHealth() {
5868
6301
  <div class="hv2-cell"><div class="hv2-lbl">Topology</div><div class="hv2-val" style="font-size:14px">${esc((_v2OrgData?.topology||'—').toUpperCase())}</div></div>
5869
6302
  ${health?.agents_active!=null?`<div class="hv2-cell"><div class="hv2-lbl">Agents</div><div class="hv2-val ${health.agents_active>0?'green':''}">${health.agents_active}</div></div>`:''}
5870
6303
  ${health?.tasks_pending!=null?`<div class="hv2-cell"><div class="hv2-lbl">Tasks</div><div class="hv2-val ${health.tasks_pending>5?'amber':''}">${health.tasks_pending}</div></div>`:''}
6304
+ ${health?.run_success_rate_7d!=null?`<div class="hv2-cell"><div class="hv2-lbl">7d Success</div><div class="hv2-val ${health.run_success_rate_7d>=80?'green':health.run_success_rate_7d<50?'amber':''}">${health.run_success_rate_7d}%</div></div>`:''}
6305
+ ${health?.total_runs_7d!=null?`<div class="hv2-cell"><div class="hv2-lbl">7d Runs</div><div class="hv2-val">${health.total_runs_7d}</div></div>`:''}
6306
+ ${health?.budget_used_pct!=null?`<div class="hv2-cell"><div class="hv2-lbl">Budget Used</div><div class="hv2-val ${health.budget_used_pct>=90?'amber':''}">${health.budget_used_pct}%</div></div>`:''}
5871
6307
  </div>
6308
+ ${health?.budget_used_pct!=null&&health?.budget_max_tokens>0?`<div style="margin:10px 0 12px"><div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden"><div style="width:${Math.min(100,health.budget_used_pct)}%;height:100%;background:${health.budget_used_pct>=90?'var(--red)':health.budget_used_pct>=70?'oklch(70% 0.18 60)':'var(--accent)'};border-radius:2px"></div></div><div style="font-size:10px;color:var(--text-lo);margin-top:4px">${(health.budget_used_tokens??0).toLocaleString()} / ${health.budget_max_tokens.toLocaleString()} tokens</div></div>`:''}
5872
6309
  ${health?.errors?.length?`<div style="margin-top:12px"><div class="m-group-title">Errors</div>${health.errors.slice(0,5).map(e=>`<div style="font-size:11px;color:var(--red);padding:4px 0;border-bottom:1px solid var(--border)">${esc(e)}</div>`).join('')}</div>`:`<div style="margin-top:10px;font-size:12px;color:var(--text-lo)">${listOrg.running?'Running — no errors detected':'Org is idle'}</div>`}
5873
6310
  `;
5874
6311
  }
@@ -5904,11 +6341,17 @@ function v2RenderOrgTasks() {
5904
6341
  if (!pane) return;
5905
6342
  // Normalize: API returns {todo:[],doing:[],done:[]} (columns) or a flat array
5906
6343
  const raw = _v2OrgData?.tasks;
6344
+ // Server puts completed/failed/cancelled in the 'done' column but keeps original status string
6345
+ const _normTaskStatus = (st, col) => {
6346
+ if (st === 'completed' || st === 'failed' || st === 'cancelled') return 'done';
6347
+ if (st === 'in_progress') return 'doing';
6348
+ return st || col;
6349
+ };
5907
6350
  let tasks = [];
5908
- if (Array.isArray(raw)) tasks = raw.slice();
6351
+ if (Array.isArray(raw)) tasks = raw.map(t => ({ ...t, status: _normTaskStatus(t.status, 'todo') }));
5909
6352
  else if (raw && typeof raw === 'object') {
5910
6353
  for (const [col, items] of Object.entries(raw)) {
5911
- (Array.isArray(items) ? items : []).forEach(t => tasks.push({ ...t, status: t.status || col }));
6354
+ (Array.isArray(items) ? items : []).forEach(t => tasks.push({ ...t, status: _normTaskStatus(t.status, col) }));
5912
6355
  }
5913
6356
  }
5914
6357
  if (!tasks.length) { pane.innerHTML = '<div class="empty">No tasks</div>'; return; }
@@ -5943,7 +6386,7 @@ function v2RenderOrgCosts() {
5943
6386
  ? c.map(r => ({ label: r.label ?? r.name ?? '—', cost: Number(r.value ?? r.cost ?? 0), tin: 0, tout: 0 }))
5944
6387
  : Object.entries(c).map(([k, v]) => ({ label: k, cost: Number(v) || 0, tin: 0, tout: 0 }));
5945
6388
  }
5946
- if (!rows.length) { pane.innerHTML = '<div class="empty">No cost data</div>'; return; }
6389
+ if (!rows.length) { pane.innerHTML = '<div class="empty" style="text-align:center;padding:24px 16px"><div style="font-size:13px;color:var(--text-lo);margin-bottom:6px">No cost data tracked yet</div><div style="font-size:11px;color:var(--text-lo);opacity:0.7">Agents must emit <code>agent:usage</code> events to populate this tab</div></div>'; return; }
5947
6390
  const cur = (b && b.currency) || 'USD';
5948
6391
  const period = (b && b.period) || '';
5949
6392
  const total = rows.reduce((s, r) => s + r.cost, 0);
@@ -5974,7 +6417,9 @@ function v2RenderOrgMembers() {
5974
6417
  const joinReqs = Array.isArray(_v2OrgData?._joinRequests) ? _v2OrgData._joinRequests : [];
5975
6418
  if (!list.length) { pane.innerHTML = '<div class="empty">No members</div>'; return; }
5976
6419
  const src = members.length ? 'joined members' : 'defined roles';
5977
- const active = (r) => r.running || r.active || r.status === 'active';
6420
+ // Overlay live running status from in-memory agent SSE state (updated by org:agent:online/offline events)
6421
+ const _liveRunning = new Set((_v2OrgData?._agents || []).filter(a => a.status === 'running').map(a => a.id));
6422
+ const active = (r) => r.running || r.active || r.status === 'active' || _liveRunning.has(r.id || r.name || r.user);
5978
6423
  pane.innerHTML = `<div class="m-group-title">Members (${list.length}) · ${src}</div>` +
5979
6424
  list.map(r => `<div style="display:flex;gap:10px;align-items:center;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
5980
6425
  <span style="width:28px;height:28px;border-radius:50%;background:var(--surface-hi);display:inline-flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0">◈</span>
@@ -5999,7 +6444,7 @@ function v2RenderOrgGoals() {
5999
6444
  const indent = depth * 20;
6000
6445
  const pct = g.total > 0 ? Math.min(100, Math.round(g.filled / g.total * 100)) : 0;
6001
6446
  const status = g.status || 'pending';
6002
- const statusCls = status === 'done' ? 'on' : status === 'blocked' ? 'warn' : '';
6447
+ const statusCls = status === 'done' ? 'on' : (status === 'blocked' || status === 'in_progress') ? 'warn' : '';
6003
6448
  return `<div style="padding:8px 0;border-bottom:1px solid var(--border);padding-left:${indent}px">
6004
6449
  <div style="display:flex;align-items:center;gap:10px">
6005
6450
  <span style="flex:1;font-size:13px;color:var(--text-hi)">${esc(g.text || g.goal || g.title || '—')}</span>
@@ -6020,9 +6465,10 @@ function v2RenderOrgBoard() {
6020
6465
  if (!issues.length) { el.innerHTML = '<div class="empty">No issues</div>'; return; }
6021
6466
  const cols = ['open', 'in_progress', 'blocked', 'done', 'cancelled'];
6022
6467
  const PRIORITY = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
6468
+ const _normIssueStatus = s => (s === 'closed' || s === 'resolved') ? 'done' : (s || 'open');
6023
6469
  el.innerHTML = `<div style="display:flex;gap:10px;overflow-x:auto;padding-bottom:8px">` +
6024
6470
  cols.map(col => {
6025
- const cards = issues.filter(i => (i.status || i.state || 'open') === col);
6471
+ const cards = issues.filter(i => _normIssueStatus(i.status || i.state) === col);
6026
6472
  return `<div style="min-width:160px;flex:1">
6027
6473
  <div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-lo);padding:6px 0;border-bottom:1px solid var(--border);margin-bottom:8px">${esc(col.replace('_', ' '))} <span style="background:var(--surface-hi);padding:1px 6px;border-radius:8px">${cards.length}</span></div>
6028
6474
  ${cards.slice(0, 15).map(i => `<div style="background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:7px 9px;margin-bottom:5px;font-size:12px">
@@ -6044,27 +6490,41 @@ function v2RenderOrgLive() {
6044
6490
  ${running.length
6045
6491
  ? running.map(a => `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
6046
6492
  <span class="live-dot" style="flex-shrink:0"></span>
6047
- <span style="font-size:13px;color:var(--text-hi)">${esc(a.type || a.title || a.id || '—')}</span>
6048
- <span style="margin-left:auto;font-size:11px;color:var(--text-lo)">${esc(a.adapter || '')}</span>
6493
+ <span style="font-size:13px;color:var(--text-hi)">${esc(a.title || a.id || '—')}</span>${a.adapterType?`<span style="font-size:10px;color:var(--text-lo);margin-left:4px">[${esc(a.adapterType)}]</span>`:''}
6494
+ <span style="margin-left:auto;font-size:11px;color:var(--text-lo)">${esc(a.adapterModel || a.adapter || '')}</span>
6049
6495
  </div>`).join('')
6050
6496
  : '<div style="color:var(--text-lo);font-size:13px;padding:10px 0">No agents currently running</div>'}
6051
6497
  </div>
6052
6498
  <div class="m-group-title" style="margin-bottom:6px">Activity Feed</div>
6053
6499
  <div style="max-height:240px;overflow-y:auto;font-size:11px;font-family:var(--mono)">
6054
- ${(_v2OrgData._activity || []).slice(-30).reverse().map(e => `<div style="padding:3px 0;border-bottom:1px solid var(--border);color:var(--text-lo)">
6055
- ${esc(relTime(e.ts || e.timestamp || e.created_at))}
6056
- <span style="color:var(--text-mid);margin-left:6px">${esc(e.type || e.kind || e.event || '—')}</span>
6057
- ${e.message ? `<span style="color:var(--text-hi);margin-left:6px">${esc(e.message.toString().slice(0, 80))}</span>` : ''}
6058
- </div>`).join('')}
6500
+ ${(()=>{const _la=_v2OrgData._activity||[];const _ls=_v2OrgEventLog[_v2SelOrg]||[];const _lseen=new Set();const _lmerged=[..._la,..._ls].filter(e=>{const k=(e.ts||'')+'|'+(e.type||'')+'|'+(e.runId||e.session||'')+'|'+(e.from||e.role||e.agent||'')+'|'+(e.result||e.msg||e.message||e.summary||'').slice(0,20);return _lseen.has(k)?false:(_lseen.add(k),true);}).sort((a,b)=>(a.ts||0)-(b.ts||0));return _lmerged.slice(-30).reverse().map(e=>`<div style="padding:3px 0;border-bottom:1px solid var(--border);color:var(--text-lo)">${esc(relTime(e.ts||e.timestamp||e.created_at))}<span style="color:var(--text-mid);margin-left:6px">${esc(e.type||e.kind||e.event||'—')}</span>${fmtOrgEvDetail(e)?`<span style="color:var(--text-hi);margin-left:6px">${esc(fmtOrgEvDetail(e).slice(0,80))}</span>`:''}</div>`).join('');})()}
6059
6501
  </div>`;
6060
6502
  // auto-refresh every 5s while tab is active
6061
6503
  if (!_orgLiveInterval) {
6062
6504
  _orgLiveInterval = setInterval(async () => {
6063
- if (_v2OrgTab !== 'live' || !_v2SelOrg) { clearInterval(_orgLiveInterval); _orgLiveInterval = null; return; }
6064
- const _enc = encodeURIComponent(_v2SelOrg);
6505
+ if (_v2OrgTab !== 'live' || !_v2SelOrg || currentView !== 'orgs') { clearInterval(_orgLiveInterval); _orgLiveInterval = null; return; }
6506
+ const _liveOrg = _v2SelOrg;
6507
+ const _enc = encodeURIComponent(_liveOrg);
6065
6508
  try {
6066
- const data = await fetch(`/api/org/${_enc}/activity${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []);
6067
- if (_v2OrgData) { _v2OrgData._activity = Array.isArray(data) ? data : []; v2RenderOrgLive(); }
6509
+ const [actData, agentsData] = await Promise.all([
6510
+ fetch(`/api/org/${_enc}/activity${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []),
6511
+ fetch(`/api/org/${_enc}/agents${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : {}).catch(() => {}),
6512
+ ]);
6513
+ if (_v2SelOrg !== _liveOrg || !_v2OrgData) return;
6514
+ // Merge fresh API data with SSE-pushed events to avoid clobbering live events
6515
+ // that haven't flushed to the JSONL file yet when this poll ran.
6516
+ const _freshAct = Array.isArray(actData) ? actData : [];
6517
+ const _sseAct = (_v2OrgData._activity || []).filter(e => e._sseOnly);
6518
+ const _mergeSeen = new Set(_freshAct.map(e => (e.ts||'')+'|'+(e.type||'')+'|'+(e.runId||e.session||'')));
6519
+ _sseAct.forEach(e => { const k=(e.ts||'')+'|'+(e.type||'')+'|'+(e.runId||e.session||''); if (!_mergeSeen.has(k)) _freshAct.push(e); });
6520
+ _v2OrgData._activity = _freshAct.sort((a,b)=>(a.ts||0)-(b.ts||0));
6521
+ const freshAgents = Array.isArray(agentsData) ? agentsData : (agentsData?.agents || []);
6522
+ if (freshAgents.length) {
6523
+ // Preserve SSE-driven running status: state file lags behind real-time SSE updates
6524
+ const sseRunning = new Set((_v2OrgData._agents || []).filter(a => a.status === 'running').map(a => a.id));
6525
+ _v2OrgData._agents = freshAgents.map(a => sseRunning.has(a.id) ? { ...a, status: 'running' } : a);
6526
+ }
6527
+ v2RenderOrgLive();
6068
6528
  } catch (_) {}
6069
6529
  }, 5000);
6070
6530
  }
@@ -6074,10 +6534,12 @@ function v2RenderOrgLive() {
6074
6534
  async function v2RenderOrgApprovals() {
6075
6535
  const el = document.getElementById('odt-approvals');
6076
6536
  if (!el || !_v2SelOrg) return;
6537
+ const _rendOrg = _v2SelOrg;
6077
6538
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6078
6539
  try {
6079
- const _enc = encodeURIComponent(_v2SelOrg);
6540
+ const _enc = encodeURIComponent(_rendOrg);
6080
6541
  const data = await fetch(`/api/org/${_enc}/approvals${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []);
6542
+ if (_v2SelOrg !== _rendOrg) return;
6081
6543
  const approvals = Array.isArray(data) ? data : (data.approvals || []);
6082
6544
  if (!approvals.length) { el.innerHTML = '<div class="empty">No pending approvals</div>'; return; }
6083
6545
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -6095,8 +6557,8 @@ async function v2RenderOrgApprovals() {
6095
6557
  <td style="padding:6px 8px;color:var(--text-xs);font-size:11px;font-family:var(--mono)">${relTime(a.created_at || a.ts)}</td>
6096
6558
  <td style="padding:6px 8px;white-space:nowrap">
6097
6559
  ${pending
6098
- ? `<button class="btn" style="font-size:10px;color:var(--green);border-color:var(--green)" title="Approve" aria-label="Approve" onclick="orgApprovalAction(${JSON.stringify(aid)},'approve')">✓</button>
6099
- <button class="btn" style="font-size:10px;color:var(--red);border-color:var(--red);margin-left:3px" title="Reject" aria-label="Reject" onclick="orgApprovalAction(${JSON.stringify(aid)},'reject')">✕</button>`
6560
+ ? `<button class="btn" style="font-size:10px;color:var(--green);border-color:var(--green)" title="Approve" aria-label="Approve" data-aid="${esc(aid)}" onclick="orgApprovalAction(this.dataset.aid,'approve')">✓</button>
6561
+ <button class="btn" style="font-size:10px;color:var(--red);border-color:var(--red);margin-left:3px" title="Reject" aria-label="Reject" data-aid="${esc(aid)}" onclick="orgApprovalAction(this.dataset.aid,'reject')">✕</button>`
6100
6562
  : ''}
6101
6563
  </td>
6102
6564
  </tr>`;
@@ -6108,7 +6570,7 @@ async function orgApprovalAction(id, action) {
6108
6570
  if (!confirm(action + ' this request?')) return;
6109
6571
  try {
6110
6572
  const _enc = encodeURIComponent(_v2SelOrg);
6111
- await fetch(`/api/org/${_enc}/approvals/${encodeURIComponent(id)}`, {
6573
+ await fetch(`/api/org/${_enc}/approvals/${encodeURIComponent(id)}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`, {
6112
6574
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action })
6113
6575
  });
6114
6576
  showToast('Done', action + 'd', 'ok');
@@ -6120,10 +6582,12 @@ async function orgApprovalAction(id, action) {
6120
6582
  async function v2RenderOrgSecrets() {
6121
6583
  const el = document.getElementById('odt-secrets');
6122
6584
  if (!el || !_v2SelOrg) return;
6585
+ const _rendOrg = _v2SelOrg;
6123
6586
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6124
6587
  try {
6125
- const _enc = encodeURIComponent(_v2SelOrg);
6588
+ const _enc = encodeURIComponent(_rendOrg);
6126
6589
  const data = await fetch(`/api/org/${_enc}/secrets${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []);
6590
+ if (_v2SelOrg !== _rendOrg) return;
6127
6591
  const secrets = Array.isArray(data) ? data : (data.secrets || []);
6128
6592
  el.innerHTML = '<div style="font-size:11px;color:var(--text-lo);margin-bottom:10px">Secret values are never transmitted or displayed.</div>' +
6129
6593
  (secrets.length
@@ -6186,10 +6650,12 @@ function generateOrgSettingsCmd() {
6186
6650
  async function v2RenderOrgRoutines() {
6187
6651
  const el = document.getElementById('odt-routines');
6188
6652
  if (!el || !_v2SelOrg) return;
6653
+ const _rendOrg = _v2SelOrg;
6189
6654
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6190
6655
  try {
6191
- const _enc = encodeURIComponent(_v2SelOrg);
6656
+ const _enc = encodeURIComponent(_rendOrg);
6192
6657
  const data = await fetch(`/api/org/${_enc}/routines${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []);
6658
+ if (_v2SelOrg !== _rendOrg) return;
6193
6659
  const routines = Array.isArray(data) ? data : (data.routines || _v2OrgData?.config?.routines || []);
6194
6660
  if (!routines.length) { el.innerHTML = '<div class="empty">No routines configured</div>'; return; }
6195
6661
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -6199,8 +6665,8 @@ async function v2RenderOrgRoutines() {
6199
6665
  routines.map(r => `<tr style="border-top:1px solid var(--border)">
6200
6666
  <td style="padding:6px 8px;color:var(--text-hi)">${esc(r.name || '—')}</td>
6201
6667
  <td style="padding:6px 8px;font-family:var(--mono);color:var(--text-lo)">${esc(r.cron || r.schedule || '—')}</td>
6202
- <td style="padding:6px 8px;color:var(--text-lo)">${r.lastRun ? relTime(r.lastRun) : '—'}</td>
6203
- <td style="padding:6px 8px"><span class="ss-pill ${r.active || r.status === 'active' ? 'on' : ''}">${esc(r.status || '—')}</span></td>
6668
+ <td style="padding:6px 8px;color:var(--text-lo)">${(r.lastRun || r.last_run) ? relTime(r.lastRun || r.last_run) : '—'}</td>
6669
+ <td style="padding:6px 8px"><span class="ss-pill ${r.enabled || r.active || r.status === 'active' ? 'on' : ''}">${esc(r.status || (r.enabled != null ? (r.enabled ? 'active' : 'inactive') : '—'))}</span></td>
6204
6670
  </tr>`).join('') + '</tbody></table>';
6205
6671
  } catch (e) { el.innerHTML = '<div class="empty">Failed: ' + esc(e.message) + '</div>'; }
6206
6672
  }
@@ -6209,10 +6675,12 @@ async function v2RenderOrgRoutines() {
6209
6675
  async function v2RenderOrgMyIssues() {
6210
6676
  const el = document.getElementById('odt-myissues');
6211
6677
  if (!el || !_v2SelOrg) return;
6678
+ const _rendOrg = _v2SelOrg;
6212
6679
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6213
6680
  try {
6214
- const _enc = encodeURIComponent(_v2SelOrg);
6681
+ const _enc = encodeURIComponent(_rendOrg);
6215
6682
  const data = await fetch(`/api/org/${_enc}/my-issues${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []);
6683
+ if (_v2SelOrg !== _rendOrg) return;
6216
6684
  const issues = Array.isArray(data) ? data : (data.issues || []);
6217
6685
  if (!issues.length) { el.innerHTML = '<div class="empty">No issues assigned to you</div>'; return; }
6218
6686
  const PRIORITY = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
@@ -6221,7 +6689,7 @@ async function v2RenderOrgMyIssues() {
6221
6689
  <th style="padding:5px 4px">P</th><th style="padding:5px 8px">Title</th><th>Status</th><th>Updated</th>
6222
6690
  </tr></thead><tbody>` +
6223
6691
  issues.slice(0, 50).map(i => {
6224
- const cls = i.status === 'done' ? 'on' : i.status === 'blocked' ? 'warn' : '';
6692
+ const cls = i.status === 'done' || i.status === 'closed' || i.status === 'resolved' ? 'on' : (i.status === 'blocked' || i.status === 'in_progress') ? 'warn' : '';
6225
6693
  return `<tr style="border-top:1px solid var(--border)">
6226
6694
  <td style="padding:5px 4px">${PRIORITY[i.priority] || '·'}</td>
6227
6695
  <td style="padding:5px 8px;color:var(--text-hi);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((i.title || i.description || '—').slice(0, 80))}</td>
@@ -6237,7 +6705,14 @@ function v2RenderOrgBudgets() {
6237
6705
  const el = document.getElementById('odt-budgets');
6238
6706
  if (!el || !_v2OrgData) return;
6239
6707
  const b = _v2OrgData._budgets || _v2OrgData.budgets || {};
6240
- const agents = _v2OrgData._agents || [];
6708
+ // Server returns { org_budget: {limit_tokens, limit_usd}, agents: [{id, title, tokens_in, tokens_out, total_cost_usd}] }
6709
+ const orgBudget = b.org_budget || {};
6710
+ const tokenLimit = orgBudget.limit_tokens || b.tokenLimit || null;
6711
+ const usdLimit = orgBudget.limit_usd || b.usdLimit || null;
6712
+ // Per-agent data comes from the budgets endpoint (has cost); fall back to agents roster
6713
+ const budgetAgents = Array.isArray(b.agents) && b.agents.length ? b.agents : [];
6714
+ const usedTokens = budgetAgents.reduce((s, a) => s + (a.tokens_in || 0) + (a.tokens_out || 0), 0);
6715
+ const usedUsd = budgetAgents.reduce((s, a) => s + (a.total_cost_usd || 0), 0);
6241
6716
 
6242
6717
  function fillBar(used, limit) {
6243
6718
  if (!limit) return '';
@@ -6248,22 +6723,25 @@ function v2RenderOrgBudgets() {
6248
6723
  }
6249
6724
 
6250
6725
  let html = '';
6251
- if (b.tokens != null || b.tokenLimit != null) {
6252
- html += `<div class="m-group-title">Tokens</div>${fillBar(b.tokens || 0, b.tokenLimit)}<div style="font-size:12px;color:var(--text-lo);margin:4px 0 14px">${Number(b.tokens || 0).toLocaleString()} / ${b.tokenLimit ? b.tokenLimit.toLocaleString() : '∞'}</div>`;
6726
+ if (tokenLimit != null || usedTokens > 0) {
6727
+ html += `<div class="m-group-title">Tokens</div>${fillBar(usedTokens, tokenLimit)}<div style="font-size:12px;color:var(--text-lo);margin:4px 0 14px">${usedTokens.toLocaleString()} / ${tokenLimit ? tokenLimit.toLocaleString() : '∞'}</div>`;
6253
6728
  }
6254
- if (b.usd != null || b.usdLimit != null) {
6255
- html += `<div class="m-group-title">USD Budget</div>${fillBar(b.usd || 0, b.usdLimit)}<div style="font-size:12px;color:var(--text-lo);margin:4px 0 14px">$${Number(b.usd || 0).toFixed(4)} / ${b.usdLimit ? '$' + Number(b.usdLimit).toFixed(2) : '∞'}</div>`;
6729
+ if (usdLimit != null || usedUsd > 0) {
6730
+ html += `<div class="m-group-title">USD Budget</div>${fillBar(usedUsd, usdLimit)}<div style="font-size:12px;color:var(--text-lo);margin:4px 0 14px">$${usedUsd.toFixed(4)} / ${usdLimit ? '$' + Number(usdLimit).toFixed(2) : '∞'}</div>`;
6256
6731
  }
6257
- if (agents.length) {
6732
+ if (budgetAgents.length) {
6258
6733
  html += '<div class="m-group-title" style="margin-bottom:6px">Per Agent</div>' +
6259
6734
  `<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="color:var(--text-xs);text-align:left"><th style="padding:4px 8px">Agent</th><th>Tokens In</th><th>Tokens Out</th><th>Cost</th></tr></thead><tbody>` +
6260
- agents.slice(0, 20).map(a => {
6261
- const over = a.budgetLimit && ((a.tokensIn || 0) + (a.tokensOut || 0)) > a.budgetLimit;
6735
+ budgetAgents.slice(0, 20).map(a => {
6736
+ const tIn = a.tokens_in || 0, tOut = a.tokens_out || 0;
6737
+ const cost = a.total_cost_usd || 0;
6738
+ const limit = a.limit_usd || orgBudget.limit_usd || 0;
6739
+ const over = limit && cost > limit;
6262
6740
  return `<tr style="border-top:1px solid var(--border)${over ? ';color:var(--red)' : ''}">
6263
- <td style="padding:4px 8px;font-family:var(--mono);font-size:11px;color:${over ? 'var(--red)' : 'var(--text-hi)'}">${esc((a.id || a.title || '—').toString().slice(0, 14))}</td>
6264
- <td style="padding:4px 8px;color:var(--text-lo)">${Number(a.tokensIn || 0).toLocaleString()}</td>
6265
- <td style="padding:4px 8px;color:var(--text-lo)">${Number(a.tokensOut || 0).toLocaleString()}</td>
6266
- <td style="padding:4px 8px;color:var(--accent)">$${Number(a.cost || 0).toFixed(4)}${over ? ' ⚠' : ''}</td>
6741
+ <td style="padding:4px 8px;font-family:var(--mono);font-size:11px;color:${over ? 'var(--red)' : 'var(--text-hi)'}">${esc((a.title || a.id || '—').toString().slice(0, 14))}</td>
6742
+ <td style="padding:4px 8px;color:var(--text-lo)">${tIn.toLocaleString()}</td>
6743
+ <td style="padding:4px 8px;color:var(--text-lo)">${tOut.toLocaleString()}</td>
6744
+ <td style="padding:4px 8px;color:var(--accent)">$${cost.toFixed(4)}${over ? ' ⚠' : ''}</td>
6267
6745
  </tr>`;
6268
6746
  }).join('') + '</tbody></table>';
6269
6747
  }
@@ -6274,10 +6752,12 @@ function v2RenderOrgBudgets() {
6274
6752
  async function v2RenderOrgPlugins() {
6275
6753
  const el = document.getElementById('odt-plugins');
6276
6754
  if (!el || !_v2SelOrg) return;
6755
+ const _rendOrg = _v2SelOrg;
6277
6756
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6278
6757
  try {
6279
- const _enc = encodeURIComponent(_v2SelOrg);
6758
+ const _enc = encodeURIComponent(_rendOrg);
6280
6759
  const data = await fetch(`/api/org/${_enc}/plugins${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`).then(r => r.ok ? r.json() : []).catch(() => []);
6760
+ if (_v2SelOrg !== _rendOrg) return;
6281
6761
  const plugins = Array.isArray(data) ? data : (data.plugins || []);
6282
6762
  if (!plugins.length) { el.innerHTML = '<div class="empty">No plugins installed</div>'; return; }
6283
6763
  el.innerHTML = `<div class="proj-grid">` +
@@ -6305,7 +6785,7 @@ function v2RenderOrgCharts() {
6305
6785
  const DAY = 86400000;
6306
6786
  const buckets = Array.from({length: 14}, (_, i) => ({
6307
6787
  day: new Date(now - (13 - i) * DAY).toLocaleDateString('en', {month:'short', day:'numeric'}),
6308
- ts: now - (13 - i) * DAY,
6788
+ ts: now - (14 - i) * DAY,
6309
6789
  events: 0, errors: 0,
6310
6790
  }));
6311
6791
  activity.forEach(e => {
@@ -6365,10 +6845,12 @@ function v2RenderOrgCharts() {
6365
6845
  async function v2RenderOrgProjects() {
6366
6846
  const el = document.getElementById('odt-projects');
6367
6847
  if (!el || !_v2SelOrg) return;
6848
+ const _rendOrg = _v2SelOrg;
6368
6849
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6369
6850
  try {
6370
- const _enc = encodeURIComponent(_v2SelOrg);
6851
+ const _enc = encodeURIComponent(_rendOrg);
6371
6852
  const data = await fetch(`/api/org/${_enc}/projects${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
6853
+ if (_v2SelOrg !== _rendOrg) return;
6372
6854
  const projects = Array.isArray(data) ? data : (data.projects || []);
6373
6855
  if (!projects.length) { el.innerHTML = '<div class="empty">No projects configured</div>'; return; }
6374
6856
  el.innerHTML = `<div class="proj-grid">${projects.map(p => `<div class="proj-card">
@@ -6421,19 +6903,37 @@ let _odtChatSessions = [];
6421
6903
  let _odtChatCurrentId = null;
6422
6904
  let _odtChatCurrentAgent = 'all';
6423
6905
  let _odtChatSseSource = null;
6906
+ let _odtChatSeenKeys = new Set();
6907
+ let _odtChatForOrg = '';
6908
+ // Loop grouping: [S] sessions with same prompt + <20 min gap → one entry
6909
+ let _odtSessionGroups = {}; // groupId → {sessions, events, status, ts, _isGroup}
6910
+ let _odtSessionGroupMap = {}; // sessionId → groupId
6911
+ // Run grouping: [R] runs with same goal (or no goal) + <30 min gap → one entry
6912
+ let _odtRunGroups = {}; // groupId → {runs, status, ts, _isRunGroup}
6913
+ let _odtRunGroupMap = {}; // runId → groupId
6424
6914
 
6425
6915
  function _odtOrgSessionMatch(s) {
6426
6916
  if (!_v2SelOrg) return true;
6427
6917
  const orgLc = _v2SelOrg.toLowerCase();
6428
6918
  const prompt = (s.prompt || '').toLowerCase();
6429
- if (prompt.includes('running org: ' + orgLc) || prompt.includes('org: ' + orgLc)) return true;
6919
+ // Use a word-boundary check so "monomind-improver" doesn't match sessions for
6920
+ // "monomind-improver-state" (the shorter name is a prefix of the longer one).
6921
+ // After the matched org name, the next char must be end-of-string or a character
6922
+ // that cannot be part of an org name (not alphanumeric, hyphen, or underscore).
6923
+ const _orgBound = (str, prefix) => {
6924
+ const idx = str.indexOf(prefix);
6925
+ if (idx === -1) return false;
6926
+ const after = str[idx + prefix.length];
6927
+ return after === undefined || !/[a-z0-9_-]/.test(after);
6928
+ };
6929
+ if (_orgBound(prompt, 'running org: ' + orgLc) || _orgBound(prompt, 'org: ' + orgLc)) return true;
6430
6930
  return (s.events || []).some(ev => (ev.org || '').toLowerCase() === orgLc);
6431
6931
  }
6432
6932
 
6433
6933
  function _odtChatAgentMatches(ev) {
6434
6934
  if (_odtChatCurrentAgent === 'all') return true;
6435
6935
  // Structural events always show regardless of agent filter
6436
- const structural = new Set(['session:start','session:complete','file:write','run:start','run:complete','run:cycle:complete','org:start','org:complete','org:agent:online']);
6936
+ const structural = new Set(['session:start','session:complete','domain:dispatch','domain:complete','file:write','run:start','run:complete','run:cycle:complete','org:start','org:complete','org:agent:online','org:agent:offline','org:checkpoint','org:error','loop:start','loop:complete','loop:tick','loop:hil','loop:hil:waiting','loop:hil:resolved']);
6437
6937
  if (structural.has(ev.type)) return true;
6438
6938
  return ev.agent === _odtChatCurrentAgent || ev.from === _odtChatCurrentAgent || ev.to === _odtChatCurrentAgent || ev.role === _odtChatCurrentAgent;
6439
6939
  }
@@ -6458,39 +6958,172 @@ async function v2RenderOrgChat() {
6458
6958
  </div>
6459
6959
  <div id="odt-chat-excerpt" class="cv-excerpt-banner"></div>
6460
6960
  <div id="odt-chat-feed">
6461
- <div id="odt-chat-empty">Select a run to see agent communications.<br><span style="font-size:10px;opacity:0.5">Runs appear after the first /mastermind:runorg</span></div>
6961
+ <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>
6462
6962
  </div>`;
6463
6963
  }
6464
6964
 
6465
- // Reset state when org changes
6466
- _odtChatCurrentId = null;
6467
- _odtChatCurrentAgent = 'all';
6468
- document.getElementById('odt-chat-agent-bar').style.display = 'none';
6469
- document.getElementById('odt-chat-feed').querySelectorAll('.cv-msg').forEach(e => e.remove());
6470
- const emptyEl = document.getElementById('odt-chat-empty');
6471
- if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'Select a run to see agent communications.'; }
6965
+ // Reset state only when org changes (not on every tab-switch)
6966
+ if (_odtChatForOrg !== _v2SelOrg) {
6967
+ _odtChatForOrg = _v2SelOrg;
6968
+ _odtChatCurrentId = null;
6969
+ _odtChatCurrentAgent = 'all';
6970
+ _odtChatSeenKeys = new Set();
6971
+ // Clear sessions from the previous org so _odtLoadChatSessions doesn't snapshot them
6972
+ // into _prevSessions and risk contaminating the new org's event buffers. SSE-injected
6973
+ // sessions for the NEW org arriving during the upcoming fetch are still captured via
6974
+ // _sseDuringFetch in _odtLoadChatSessions.
6975
+ _odtChatSessions = [];
6976
+ document.getElementById('odt-chat-agent-bar').style.display = 'none';
6977
+ document.getElementById('odt-chat-feed').querySelectorAll('.cv-msg, .cv-session-hdr').forEach(e => e.remove());
6978
+ const emptyEl = document.getElementById('odt-chat-empty');
6979
+ if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'Select a session to see agent communications.'; }
6980
+ }
6472
6981
 
6473
6982
  await _odtLoadChatSessions();
6983
+ // Returning to chat tab (same org, session already selected): re-render feed to catch
6984
+ // up on events buffered in sess.events while user was on another tab. _odtLoadChatSessions
6985
+ // refreshes the run list but won't re-render the feed when _odtChatCurrentId is already set.
6986
+ if (_odtChatCurrentId) {
6987
+ const _catchSess = _odtChatSessions.find(s => s.id === _odtChatCurrentId);
6988
+ if (_catchSess && (_catchSess._eventsLoaded || !_catchSess._isOrgRun)) {
6989
+ const _catchFeed = document.getElementById('odt-chat-feed');
6990
+ if (_catchFeed) {
6991
+ _catchFeed.querySelectorAll('.cv-msg, .cv-session-hdr').forEach(e => e.remove());
6992
+ // Re-inject session header for session-based (non-_isOrgRun) sessions — was removed above
6993
+ if (!_catchSess._isOrgRun) {
6994
+ const _cTs = _catchSess.ts || _catchSess.startedAt || 0;
6995
+ const _cTsStr = _cTs ? new Date(_cTs).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
6996
+ const _cPrompt = (_catchSess.prompt || '').replace(/^running org:\s*/i, '').slice(0, 100) || _odtChatCurrentId;
6997
+ const _cSt = _catchSess.status || 'unknown';
6998
+ const _cStCls = _cSt === 'running' ? 'color:oklch(65% 0.20 145)' : _cSt === 'complete' ? 'color:var(--text-lo)' : 'color:var(--text-xs)';
6999
+ const _cHdr = document.createElement('div');
7000
+ _cHdr.className = 'cv-session-hdr';
7001
+ _cHdr.innerHTML = `<div class="cv-shdr-prompt">${esc(_cPrompt)}</div>`+
7002
+ `<div class="cv-shdr-meta">`+
7003
+ `<span class="cv-shdr-id" title="${esc(_odtChatCurrentId)}">${esc(_odtChatCurrentId.slice(0,22))}</span>`+
7004
+ `<span class="cv-shdr-sep">·</span><span style="${_cStCls}">${esc(_cSt)}</span>`+
7005
+ `<span class="cv-shdr-sep">·</span><span>${esc(_cTsStr)}</span>`+
7006
+ `<span class="cv-shdr-sep">·</span><span class="cv-shdr-evcount">${(_catchSess.events||[]).length} events</span>`+
7007
+ `</div>`;
7008
+ _catchFeed.appendChild(_cHdr);
7009
+ }
7010
+ (_catchSess.events || []).forEach(ev => {
7011
+ _odtAppendEvent(ev, false);
7012
+ const _ck = (ev?.ts||'')+'|'+(ev?.type||'')+'|'+(ev?.runId||ev?.session||'')+'|'+(ev?.from||ev?.role||ev?.agent||'')+'|'+(ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
7013
+ _odtChatSeenKeys.add(_ck);
7014
+ });
7015
+ // Re-inject [S]→[R] link card on tab-return (same as first-select path)
7016
+ if (!_catchSess._isOrgRun) {
7017
+ const _crLinkedId = 'run-' + (_catchSess.id || '').replace(/^mm-/, '');
7018
+ const _crLinked = _odtChatSessions.find(s => s._isOrgRun && s.id === _crLinkedId);
7019
+ if (_crLinked) {
7020
+ const _crLinkEl = document.createElement('div');
7021
+ _crLinkEl.className = 'cv-msg cv-sys';
7022
+ const _crGoal = (_crLinked.prompt || '').slice(0, 60) || _crLinkedId;
7023
+ _crLinkEl.innerHTML = `<div class="cv-bub" style="cursor:pointer;border-left:2px solid var(--accent);background:oklch(15% 0.05 220)">`+
7024
+ `<span class="cv-etype" style="color:var(--accent)">RUN</span>`+
7025
+ `<span class="cv-text">→ ${esc(_crGoal)}${_crGoal.length >= 60 ? '…' : ''}</span>`+
7026
+ `<span class="cv-ts" style="color:var(--accent)">[${esc(_crLinked.status||'?')}]</span></div>`;
7027
+ _crLinkEl.onclick = () => {
7028
+ const _crSel = document.getElementById('odt-chat-sel');
7029
+ if (_crSel) { _crSel.value = _crLinkedId; odtChatSelectSession(_crLinkedId); }
7030
+ };
7031
+ _catchFeed.appendChild(_crLinkEl);
7032
+ }
7033
+ }
7034
+ _catchFeed.scrollTop = _catchFeed.scrollHeight;
7035
+ }
7036
+ // Refresh agent bar — new agents may have come online while on another tab
7037
+ const _catchOrgRoles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
7038
+ _odtPopulateAgentBar(_catchSess.events || [], _catchOrgRoles);
7039
+ // Refresh excerpt banner for all session types so last-activity stays current after tab-return
7040
+ if ((_catchSess.events || []).length) {
7041
+ renderCvExcerptBanner('odt-chat-excerpt', _catchSess.events, _catchSess);
7042
+ }
7043
+ } else if (_catchSess && _catchSess._isOrgRun && !_catchSess._eventsLoaded && !_catchSess._loading) {
7044
+ // Session selected but events not yet loaded (object reference may have been replaced by
7045
+ // _odtLoadChatSessions while lazy-load was in-flight). Retrigger select to lazy-load events
7046
+ // so returning to Chat tab never shows a permanently empty feed.
7047
+ // Guard _loading: if a fetch is already in-flight (user tabbed away/back during the fetch)
7048
+ // do NOT fire a second parallel fetch — it would race and overwrite sess.events on settle.
7049
+ odtChatSelectSession(_odtChatCurrentId);
7050
+ }
7051
+ }
6474
7052
  _odtConnectChatSSE();
6475
7053
  }
6476
7054
 
6477
7055
  async function _odtLoadChatSessions() {
6478
7056
  if (!_v2SelOrg) return;
7057
+ const _loadedForOrg = _v2SelOrg;
7058
+ // Snapshot existing sessions to preserve event caches across refreshes.
7059
+ // Do NOT reset _odtChatSessions to [] here — SSE handlers may inject new
7060
+ // sessions into the live array during the await below, and a reset would
7061
+ // cause them to be lost when we overwrite with the API response.
7062
+ const _prevSessions = _odtChatSessions.slice();
6479
7063
  try {
6480
7064
  const dirParam = (typeof DIR !== 'undefined' && DIR) ? '&dir='+encodeURIComponent(DIR) : '';
6481
7065
  // Primary: structured org run files
6482
- const runs = await apiFetch('/api/org/'+encodeURIComponent(_v2SelOrg)+'/runs?x=1'+dirParam);
7066
+ const runs = await apiFetch('/api/org/'+encodeURIComponent(_loadedForOrg)+'/runs?x=1'+dirParam);
7067
+ if (_v2SelOrg !== _loadedForOrg) return;
6483
7068
  if (Array.isArray(runs) && runs.length) {
6484
- _odtChatSessions = runs.map(r => ({
6485
- id: r.runId,
6486
- ts: r.startedAt,
6487
- prompt: r.goal || r.runId,
6488
- status: r.status,
6489
- _isOrgRun: true,
6490
- _runMeta: r,
6491
- events: [], // loaded lazily in odtChatSelectSession
6492
- }));
7069
+ // Capture any sessions SSE-injected DURING the await (not in pre-fetch snapshot)
7070
+ const _sseDuringFetch = _odtChatSessions.filter(s => !_prevSessions.find(p => p.id === s.id));
7071
+ _odtChatSessions = runs.map(r => {
7072
+ const prev = _prevSessions.find(s => s.id === r.runId) || _sseDuringFetch.find(s => s.id === r.runId);
7073
+ // Preserve the SSE-incremented cycleCount/eventCount when they exceed the disk-based API values.
7074
+ // The JSONL file may not have flushed the latest checkpoint yet when this fetch resolves,
7075
+ // so the API count can be lower than what SSE has already observed — causing a visible
7076
+ // backwards jump in the "Ncyc" label if we blindly replace with the API value.
7077
+ const _prevMeta = prev?._runMeta;
7078
+ const _mergedMeta = _prevMeta
7079
+ ? { ...r, cycleCount: Math.max(r.cycleCount || 0, _prevMeta.cycleCount || 0), eventCount: Math.max(r.eventCount || 0, _prevMeta.eventCount || 0) }
7080
+ : r;
7081
+ return {
7082
+ id: r.runId,
7083
+ ts: r.startedAt,
7084
+ prompt: r.goal || r.runId,
7085
+ status: r.status,
7086
+ _isOrgRun: true,
7087
+ _runMeta: _mergedMeta,
7088
+ events: prev ? prev.events : [],
7089
+ _eventsLoaded: prev ? prev._eventsLoaded : false,
7090
+ };
7091
+ });
7092
+ // Re-attach SSE-only sessions from original snapshot not in API response
7093
+ _prevSessions.forEach(ps => {
7094
+ if (!_odtChatSessions.find(s => s.id === ps.id)) _odtChatSessions.push(ps);
7095
+ });
7096
+ // Re-attach sessions SSE-injected DURING the fetch (not in snapshot, not in API)
7097
+ _sseDuringFetch.forEach(ss => {
7098
+ if (!_odtChatSessions.find(s => s.id === ss.id)) _odtChatSessions.push(ss);
7099
+ });
7100
+ // Sort most-recent-first so SSE-injected new runs (appended above) appear at top of dropdown
7101
+ _odtChatSessions.sort((a, b) => (b.ts || b.startedAt || 0) - (a.ts || a.startedAt || 0));
6493
7102
  _odtPopulateChatSel();
7103
+ // Also merge mastermind sessions matching this org — they are distinct from org
7104
+ // runs (no runId/JSONL file) but contain lifecycle events the user needs to see.
7105
+ // Orgs running via _repeat loops emit session:start/complete events to the mastermind
7106
+ // event store; these never appear in the run list and are otherwise invisible.
7107
+ try {
7108
+ const _mmSessR = await apiFetch('/api/mastermind/sessions');
7109
+ if (_v2SelOrg === _loadedForOrg) {
7110
+ const _mmAll = Array.isArray(_mmSessR) ? _mmSessR : Object.values(_mmSessR.sessions || {});
7111
+ const _mmMatch = _mmAll.filter(_odtOrgSessionMatch).filter(s => !_odtChatSessions.find(x => x.id === s.id));
7112
+ if (_mmMatch.length) {
7113
+ _mmMatch.forEach(s => {
7114
+ if (s.status && s.status !== 'running' && s.status !== 'complete') s.status = 'complete';
7115
+ // Mark events as loaded for complete sessions that already carry the full events[]
7116
+ // from /api/mastermind/sessions — avoids a redundant /api/mastermind/session/:id
7117
+ // fetch (and the "Loading session events…" spinner) on first select.
7118
+ // Running sessions are NOT marked loaded so they still get a fresh fetch.
7119
+ if (s.status !== 'running' && Array.isArray(s.events) && s.events.length > 0) s._eventsLoaded = true;
7120
+ });
7121
+ _odtChatSessions = _odtChatSessions.concat(_mmMatch);
7122
+ _odtChatSessions.sort((a, b) => (b.ts || b.startedAt || 0) - (a.ts || a.startedAt || 0));
7123
+ _odtPopulateChatSel();
7124
+ }
7125
+ }
7126
+ } catch(_) {}
6494
7127
  if (!_odtChatCurrentId && _odtChatSessions.length) {
6495
7128
  const first = _odtChatSessions[0];
6496
7129
  const sel = document.getElementById('odt-chat-sel');
@@ -6499,38 +7132,190 @@ async function _odtLoadChatSessions() {
6499
7132
  return;
6500
7133
  }
6501
7134
  } catch(_) {}
7135
+ if (_v2SelOrg !== _loadedForOrg) return;
6502
7136
  // Fallback: mastermind lifecycle events (pre-run-file era sessions)
6503
7137
  try {
6504
7138
  const r = await apiFetch('/api/mastermind/sessions');
7139
+ if (_v2SelOrg !== _loadedForOrg) return;
6505
7140
  const all = Array.isArray(r) ? r : Object.values(r.sessions || {});
6506
- _odtChatSessions = all.filter(_odtOrgSessionMatch);
7141
+ _odtChatSessions = all.filter(_odtOrgSessionMatch).map(s => {
7142
+ if (s.status && s.status !== 'running' && s.status !== 'complete') s.status = 'complete';
7143
+ // Mark complete sessions with events already loaded — avoids redundant lazy-load fetch
7144
+ if (s.status !== 'running' && Array.isArray(s.events) && s.events.length > 0) s._eventsLoaded = true;
7145
+ return s;
7146
+ });
7147
+ // Re-attach SSE-injected org run placeholders that aren't in the mastermind sessions API.
7148
+ // These are created by _odtHandleLiveEvent when a run:start arrives before the JSONL file
7149
+ // flushes to disk — without this, switching to the fallback path drops them.
7150
+ _prevSessions.forEach(ps => {
7151
+ if (ps._isOrgRun && !_odtChatSessions.find(s => s.id === ps.id)) _odtChatSessions.push(ps);
7152
+ });
7153
+ _odtChatSessions.sort((a, b) => (b.ts || b.startedAt || 0) - (a.ts || a.startedAt || 0));
6507
7154
  _odtPopulateChatSel();
7155
+ // Auto-select most recent session when nothing is running (mirrors primary path behaviour).
7156
+ // _odtPopulateChatSel only picks running sessions; for all-complete orgs the feed stays
7157
+ // blank without this.
7158
+ if (!_odtChatCurrentId && _odtChatSessions.length) {
7159
+ const _fbFirst = _odtChatSessions[0];
7160
+ const _fbSel = document.getElementById('odt-chat-sel');
7161
+ if (_fbSel) { _fbSel.value = _fbFirst.id; odtChatSelectSession(_fbFirst.id); }
7162
+ }
6508
7163
  } catch(_) {}
6509
7164
  }
6510
7165
 
7166
+ function _odtRebuildRunGroups() {
7167
+ // Group consecutive [R] runs within 30 min with the same goal (or both without a goal).
7168
+ const _ODT_RUN_GAP = 30 * 60 * 1000;
7169
+ const _norm = p => (p || '').toLowerCase().trim().slice(0, 40);
7170
+ _odtRunGroups = {};
7171
+ _odtRunGroupMap = {};
7172
+ const runs = _odtChatSessions.filter(s => s._isOrgRun)
7173
+ .slice().sort((a, b) => (a.ts||a.startedAt||0) - (b.ts||b.startedAt||0));
7174
+ const groups = [];
7175
+ runs.forEach(s => {
7176
+ // No-goal runs (prompt === id) get key '' — group all consecutive ones
7177
+ const key = (s.prompt && s.prompt !== s.id) ? _norm(s.prompt) : '';
7178
+ const sAt = s.ts || s.startedAt || 0;
7179
+ const last = groups[groups.length - 1];
7180
+ if (last && last._normKey === key && (sAt - last._newestAt) < _ODT_RUN_GAP) {
7181
+ last.runs.push(s);
7182
+ last._newestAt = sAt;
7183
+ if (s.status === 'running') last.status = 'running';
7184
+ else if (s.status === 'stale' && last.status !== 'running' && last.status !== 'complete') last.status = 'stale';
7185
+ else if (s.status === 'complete' && last.status !== 'running') last.status = 'complete';
7186
+ } else {
7187
+ groups.push({ id: s.id, runs: [s], _normKey: key, _newestAt: sAt,
7188
+ status: s.status || 'complete', prompt: s.prompt, ts: sAt, _isRunGroup: false });
7189
+ }
7190
+ });
7191
+ groups.forEach(g => {
7192
+ if (g.runs.length > 1) {
7193
+ g._isRunGroup = true;
7194
+ g.id = 'rgrp:' + g.runs[0].id;
7195
+ g.ts = g._newestAt;
7196
+ // Use the most informative goal among sub-runs
7197
+ const withGoal = g.runs.find(r => r.prompt && r.prompt !== r.id);
7198
+ if (withGoal) g.prompt = withGoal.prompt;
7199
+ }
7200
+ _odtRunGroups[g.id] = g;
7201
+ g.runs.forEach(r => { _odtRunGroupMap[r.id] = g.id; });
7202
+ });
7203
+ }
7204
+
7205
+ function _odtGetMergedRunGroupEvents(group) {
7206
+ const all = [];
7207
+ (group.runs || []).forEach(r => (r.events || []).forEach(ev => all.push(ev)));
7208
+ all.sort((a, b) => (a.ts||0) - (b.ts||0));
7209
+ return all;
7210
+ }
7211
+
7212
+ function _odtRebuildSessionGroups() {
7213
+ // Group [S] sessions (loop reps) that share same prompt + gap <20 min into one entry.
7214
+ // [R] runs are never grouped — each is its own run.
7215
+ const _ODT_MAX_GAP = 20 * 60 * 1000;
7216
+ const _odtNorm = p => (p || '').replace(/^running org:\s*/i, '').toLowerCase().replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 40);
7217
+ _odtSessionGroups = {};
7218
+ _odtSessionGroupMap = {};
7219
+ // Process [S] sessions only, sorted oldest first
7220
+ const sSessions = _odtChatSessions.filter(s => !s._isOrgRun)
7221
+ .slice().sort((a, b) => (a.ts||a.startedAt||0) - (b.ts||b.startedAt||0));
7222
+ const groups = [];
7223
+ sSessions.forEach(s => {
7224
+ const key = _odtNorm(s.prompt);
7225
+ const sAt = s.ts || s.startedAt || 0;
7226
+ const last = groups[groups.length - 1];
7227
+ if (last && last._normKey === key && key && (sAt - last._newestAt) < _ODT_MAX_GAP) {
7228
+ last.sessions.push(s);
7229
+ last._newestAt = sAt;
7230
+ last.totalEvents += (s.events || []).length;
7231
+ if (s.status === 'running') last.status = 'running';
7232
+ } else {
7233
+ groups.push({ id: s.id, sessions: [s], _normKey: key, _newestAt: sAt,
7234
+ totalEvents: (s.events || []).length, status: s.status || 'complete',
7235
+ prompt: s.prompt, ts: sAt, _isGroup: false });
7236
+ }
7237
+ });
7238
+ // Finalize: multi-session groups get a virtual ID + merged events
7239
+ groups.forEach(g => {
7240
+ if (g.sessions.length > 1) {
7241
+ g._isGroup = true;
7242
+ g.id = 'grp:' + g.sessions[0].id;
7243
+ g.ts = g._newestAt; // show newest timestamp in dropdown
7244
+ }
7245
+ _odtSessionGroups[g.id] = g;
7246
+ g.sessions.forEach(s => { _odtSessionGroupMap[s.id] = g.id; });
7247
+ });
7248
+ }
7249
+
7250
+ function _odtGetMergedGroupEvents(group) {
7251
+ // Merge events from all sessions in a group, time-sorted
7252
+ const all = [];
7253
+ (group.sessions || []).forEach(s => (s.events || []).forEach(ev => all.push(ev)));
7254
+ all.sort((a, b) => (a.ts||0) - (b.ts||0));
7255
+ return all;
7256
+ }
7257
+
6511
7258
  function _odtPopulateChatSel() {
6512
7259
  const sel = document.getElementById('odt-chat-sel');
6513
7260
  if (!sel) return;
7261
+ if (document.activeElement === sel) return;
7262
+ // Rebuild groups from current sessions + runs
7263
+ _odtRebuildSessionGroups();
7264
+ _odtRebuildRunGroups();
6514
7265
  const prev = _odtChatCurrentId || sel.value;
6515
- const count = _odtChatSessions.length;
6516
- sel.innerHTML = `<option value="">${count ? `— ${count} run${count !== 1 ? 's' : ''} for ${_v2SelOrg} —` : '— no runs yet —'}</option>`;
6517
- _odtChatSessions.forEach(s => {
6518
- const d = new Date(s.ts || s.startedAt || 0);
7266
+ const runGroups = Object.values(_odtRunGroups).sort((a, b) => b.ts - a.ts);
7267
+ const sessGroups = Object.values(_odtSessionGroups).sort((a, b) => b.ts - a.ts);
7268
+ const totalEntries = runGroups.length + sessGroups.length;
7269
+ const hasRuns = runGroups.length > 0;
7270
+ const hasSess = sessGroups.length > 0;
7271
+ const entryLabel = (hasRuns && hasSess) ? 'entries' : hasRuns ? 'run' + (runGroups.length !== 1 ? 's' : '') : 'loop' + (sessGroups.length !== 1 ? 's' : '');
7272
+ sel.innerHTML = `<option value="">${totalEntries ? `— ${totalEntries} ${entryLabel} for ${_v2SelOrg} —` : '— no runs yet —'}</option>`;
7273
+
7274
+ // Render [R] run groups
7275
+ runGroups.forEach(g => {
7276
+ const d = new Date(g.ts || 0);
6519
7277
  const ts = d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});
6520
7278
  const date = d.toLocaleDateString([],{month:'short',day:'numeric'});
6521
- const prompt = (s.prompt || '').replace(/^running org:\s*/i, '').slice(0,40);
6522
- const meta = s._runMeta;
6523
- const extra = meta ? ` · ${meta.cycleCount||0}cyc · ${meta.eventCount||0}ev` : '';
7279
+ const _rawPrompt = (g.prompt || '').replace(/^running org:\s*/i, '').trim();
7280
+ const prompt = (_rawPrompt && _rawPrompt !== g.runs[0]?.id) ? _rawPrompt.slice(0, 30) : '(auto-run)';
7281
+ const runsPart = g._isRunGroup ? ` · ${g.runs.length} runs` : '';
7282
+ // Total event count from run metadata
7283
+ const totalEvs = g.runs.reduce((sum, r) => sum + ((r._runMeta?.eventCount || 0) || (r.events || []).length), 0);
7284
+ const totalCyc = g.runs.reduce((sum, r) => sum + (r._runMeta?.cycleCount || 0), 0);
7285
+ const evPart = totalEvs > 0 ? ` · ${totalCyc}cyc · ${totalEvs}ev` : '';
6524
7286
  const opt = document.createElement('option');
6525
- opt.value = s.id;
6526
- opt.textContent = `${date} ${ts} ${prompt}${prompt.length>=40?'…':''}${extra} [${s.status||'?'}]`;
6527
- if (s.id === prev) opt.selected = true;
7287
+ opt.value = g.id;
7288
+ opt.textContent = `[R] ${date} ${ts} ${prompt}${_rawPrompt.length>30?'…':''}${runsPart}${evPart} [${g.status||'?'}]`;
7289
+ if (g.id === prev || g.runs.some(r => r.id === prev)) opt.selected = true;
6528
7290
  sel.appendChild(opt);
6529
7291
  });
6530
- // Auto-select running session
7292
+
7293
+ // Render [S] groups (collapsed loop reps)
7294
+ sessGroups.forEach(g => {
7295
+ const d = new Date(g.ts || 0);
7296
+ const ts = d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});
7297
+ const date = d.toLocaleDateString([],{month:'short',day:'numeric'});
7298
+ const _rawPrompt = (g.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim();
7299
+ const prompt = _rawPrompt.slice(0, 28) || '(loop)';
7300
+ const repPart = g._isGroup ? ` · ${g.sessions.length} reps` : '';
7301
+ const evPart = g.totalEvents > 0 ? ` · ${g.totalEvents}ev` : '';
7302
+ const opt = document.createElement('option');
7303
+ opt.value = g.id;
7304
+ opt.textContent = `[S] ${date} ${ts} ${prompt}${_rawPrompt.length>28?'…':''}${repPart}${evPart} [${g.status||'?'}]`;
7305
+ // Selection match: group ID OR any session in the group
7306
+ if (g.id === prev || g.sessions.some(s => s.id === prev)) opt.selected = true;
7307
+ sel.appendChild(opt);
7308
+ });
7309
+
7310
+ // Update label
7311
+ const _lblEl = document.getElementById('odt-chat-sess-lbl');
7312
+ if (_lblEl) _lblEl.textContent = (hasRuns && hasSess) ? 'HISTORY' : hasRuns ? 'RUN' : 'SESSION';
7313
+ // Auto-select running entry
6531
7314
  if (!_odtChatCurrentId) {
6532
- const running = _odtChatSessions.find(s => s.status === 'running');
6533
- if (running) { sel.value = running.id; odtChatSelectSession(running.id); }
7315
+ const runningRunGrp = runGroups.find(g => g.status === 'running');
7316
+ const runningSessGrp = sessGroups.find(g => g.status === 'running');
7317
+ const first = runningRunGrp || runningSessGrp || runGroups[0] || sessGroups[0];
7318
+ if (first) { sel.value = first.id; odtChatSelectSession(first.id); }
6534
7319
  }
6535
7320
  }
6536
7321
 
@@ -6540,36 +7325,254 @@ window.odtChatSelectSession = async function(id) {
6540
7325
  const feed = document.getElementById('odt-chat-feed');
6541
7326
  const emptyEl = document.getElementById('odt-chat-empty');
6542
7327
  const bar = document.getElementById('odt-chat-agent-bar');
6543
- feed.querySelectorAll('.cv-msg').forEach(e => e.remove());
7328
+ feed.querySelectorAll('.cv-msg, .cv-session-hdr').forEach(e => e.remove());
6544
7329
  if (!id) {
6545
7330
  if (emptyEl) emptyEl.style.display = 'block';
6546
7331
  if (bar) bar.style.display = 'none';
6547
7332
  return;
6548
7333
  }
7334
+
7335
+ // ── Run group path: render merged events from all runs in the group ──
7336
+ if (id.startsWith('rgrp:') && _odtRunGroups[id]) {
7337
+ const rgrp = _odtRunGroups[id];
7338
+ if (emptyEl) emptyEl.style.display = 'none';
7339
+ // Lazy-load each sub-run's events that haven't been loaded yet
7340
+ for (const r of rgrp.runs) {
7341
+ if (!r._eventsLoaded && !r._loading) {
7342
+ const _ldr = Object.assign(document.createElement('div'), { id: 'odt-chat-loading', style: 'text-align:center;padding:24px 0;font-size:11px;opacity:0.5;color:var(--text-mid)' });
7343
+ _ldr.textContent = `Loading ${rgrp.runs.length} runs…`;
7344
+ if (feed) feed.appendChild(_ldr);
7345
+ r._loading = true;
7346
+ try {
7347
+ const _dp = (typeof DIR !== 'undefined' && DIR) ? '?dir='+encodeURIComponent(DIR) : '';
7348
+ const evs = await apiFetch('/api/org/'+encodeURIComponent(_v2SelOrg)+'/runs/'+encodeURIComponent(r.id)+_dp);
7349
+ r.events = Array.isArray(evs) ? evs : (r.events || []);
7350
+ r._eventsLoaded = true;
7351
+ } catch(_) { r._eventsLoaded = true; }
7352
+ r._loading = false;
7353
+ _ldr.remove();
7354
+ if (_odtChatCurrentId !== id) return;
7355
+ }
7356
+ }
7357
+ // Re-render from scratch (all runs now loaded)
7358
+ feed.querySelectorAll('.cv-msg, .cv-session-hdr').forEach(e => e.remove());
7359
+ const mergedEvs = _odtGetMergedRunGroupEvents(rgrp);
7360
+ const _rgrpLabel = (rgrp.prompt && rgrp.prompt !== rgrp.runs[0]?.id)
7361
+ ? (rgrp.prompt || '').replace(/^running org:\s*/i, '').trim().slice(0, 80)
7362
+ : '(auto-run)';
7363
+ const _rgrpTs = rgrp.ts || 0;
7364
+ const _rgrpTsStr = _rgrpTs ? new Date(_rgrpTs).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
7365
+ const _rShdr = document.createElement('div');
7366
+ _rShdr.className = 'cv-session-hdr';
7367
+ _rShdr.innerHTML = `<div class="cv-shdr-prompt">${esc(_rgrpLabel)}</div>`+
7368
+ `<div class="cv-shdr-meta">`+
7369
+ `<span class="cv-shdr-id">${rgrp.runs.length} run${rgrp.runs.length!==1?'s':''}</span>`+
7370
+ `<span class="cv-shdr-sep">·</span>`+
7371
+ `<span>${esc(_rgrpTsStr)}</span>`+
7372
+ `<span class="cv-shdr-sep">·</span>`+
7373
+ `<span class="cv-shdr-evcount">${mergedEvs.length} events</span>`+
7374
+ `</div>`;
7375
+ feed.appendChild(_rShdr);
7376
+ const orgRoles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
7377
+ _odtPopulateAgentBar(mergedEvs, orgRoles);
7378
+ mergedEvs.forEach(ev => {
7379
+ _odtAppendEvent(ev, false);
7380
+ const _hk = (ev?.ts||'')+'|'+(ev?.type||'')+'|'+(ev?.runId||ev?.session||'')+'|'+(ev?.from||ev?.role||ev?.agent||'')+'|'+(ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
7381
+ _odtChatSeenKeys.add(_hk);
7382
+ });
7383
+ const ex = document.getElementById('odt-chat-excerpt');
7384
+ if (ex && mergedEvs.length) renderCvExcerptBanner('odt-chat-excerpt', mergedEvs, rgrp);
7385
+ if (!feed.querySelector('.cv-msg') && emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'No events yet.'; }
7386
+ feed.scrollTop = feed.scrollHeight;
7387
+ return;
7388
+ }
7389
+
7390
+ // ── Group path: render merged events from all sessions in the group ──
7391
+ if (id.startsWith('grp:') && _odtSessionGroups[id]) {
7392
+ const grp = _odtSessionGroups[id];
7393
+ if (emptyEl) emptyEl.style.display = 'none';
7394
+ // Lazy-load each session's events that haven't been loaded yet
7395
+ for (const s of grp.sessions) {
7396
+ if (!s._eventsLoaded) {
7397
+ const _fbLoader = Object.assign(document.createElement('div'), { id: 'odt-chat-loading', style: 'text-align:center;padding:24px 0;font-size:11px;opacity:0.5;color:var(--text-mid)' });
7398
+ _fbLoader.textContent = `Loading ${grp.sessions.length} loop sessions…`;
7399
+ if (feed) feed.appendChild(_fbLoader);
7400
+ s._loading = true;
7401
+ try {
7402
+ const _fbData = await apiFetch('/api/mastermind/session/'+encodeURIComponent(s.id));
7403
+ s.events = Array.isArray(_fbData.events) ? _fbData.events : (s.events || []);
7404
+ s._eventsLoaded = true;
7405
+ } catch(_) { s._eventsLoaded = true; }
7406
+ s._loading = false;
7407
+ _fbLoader.remove();
7408
+ if (_odtChatCurrentId !== id) return;
7409
+ break; // Load one at a time; subsequent repaint will finish others
7410
+ }
7411
+ }
7412
+ const mergedEvs = _odtGetMergedGroupEvents(grp);
7413
+ const _normKey = (grp.prompt || '').replace(/^running org:\s*/i, '').replace(/\s*rep\s+\d+.*$/i, '').trim().slice(0, 80) || id;
7414
+ const _shdrTs = grp.ts || 0;
7415
+ const _shdrTsStr = _shdrTs ? new Date(_shdrTs).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
7416
+ const _shdr = document.createElement('div');
7417
+ _shdr.className = 'cv-session-hdr';
7418
+ _shdr.innerHTML = `<div class="cv-shdr-prompt">${esc(_normKey)}</div>`+
7419
+ `<div class="cv-shdr-meta">`+
7420
+ `<span class="cv-shdr-id" title="${esc(id)}">${grp.sessions.length} reps</span>`+
7421
+ `<span class="cv-shdr-sep">·</span>`+
7422
+ `<span>${esc(_shdrTsStr)}</span>`+
7423
+ `<span class="cv-shdr-sep">·</span>`+
7424
+ `<span class="cv-shdr-evcount">${mergedEvs.length} events</span>`+
7425
+ `</div>`;
7426
+ feed.appendChild(_shdr);
7427
+ const orgRoles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
7428
+ _odtPopulateAgentBar(mergedEvs, orgRoles);
7429
+ mergedEvs.forEach(ev => {
7430
+ _odtAppendEvent(ev, false);
7431
+ const _hk = (ev?.ts||'')+'|'+(ev?.type||'')+'|'+(ev?.runId||ev?.session||'')+'|'+(ev?.from||ev?.role||ev?.agent||'')+'|'+(ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
7432
+ _odtChatSeenKeys.add(_hk);
7433
+ });
7434
+ const ex = document.getElementById('odt-chat-excerpt');
7435
+ if (ex && mergedEvs.length) renderCvExcerptBanner('odt-chat-excerpt', mergedEvs, grp);
7436
+ if (!feed.querySelector('.cv-msg') && emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = 'No events yet.'; }
7437
+ feed.scrollTop = feed.scrollHeight;
7438
+ return;
7439
+ }
7440
+
6549
7441
  const sess = _odtChatSessions.find(s => s.id === id);
6550
7442
  if (!sess) return;
6551
7443
  if (emptyEl) emptyEl.style.display = 'none';
7444
+ // Lazy-load events for fallback (non-_isOrgRun) sessions from /api/mastermind/session/:id
7445
+ if (!sess._isOrgRun && !sess._eventsLoaded) {
7446
+ const _fbFeed = document.getElementById('odt-chat-feed');
7447
+ const _fbLoader = Object.assign(document.createElement('div'), { id: 'odt-chat-loading', style: 'text-align:center;padding:24px 0;font-size:11px;opacity:0.5;color:var(--text-mid)' });
7448
+ _fbLoader.textContent = 'Loading session events…';
7449
+ if (_fbFeed) _fbFeed.appendChild(_fbLoader);
7450
+ const _fbPreLoad = (sess.events || []).slice();
7451
+ sess._loading = true;
7452
+ try {
7453
+ const _fbData = await apiFetch('/api/mastermind/session/'+encodeURIComponent(id));
7454
+ const _fbSseDuring = (sess.events || []).filter(e => !_fbPreLoad.find(p => p === e));
7455
+ sess.events = Array.isArray(_fbData.events) ? _fbData.events : _fbPreLoad;
7456
+ _fbSseDuring.forEach(e => {
7457
+ if (!sess.events.find(s => s.ts === e.ts && s.type === e.type && (s.from||'') === (e.from||''))) sess.events.push(e);
7458
+ });
7459
+ sess._eventsLoaded = true;
7460
+ } catch(_) {
7461
+ sess._eventsLoaded = true;
7462
+ }
7463
+ sess._loading = false;
7464
+ // Sync back — _odtLoadChatSessions may have replaced the object reference during the await
7465
+ const _fbLive = _odtChatSessions.find(s => s.id === id);
7466
+ if (_fbLive && _fbLive !== sess) { _fbLive._eventsLoaded = true; _fbLive._loading = false; _fbLive.events = sess.events; }
7467
+ _fbLoader.remove();
7468
+ if (_odtChatCurrentId !== id) return;
7469
+ }
6552
7470
  // Lazy-load events for org run sessions
6553
7471
  if (sess._isOrgRun && !sess._eventsLoaded) {
6554
7472
  const feed2 = document.getElementById('odt-chat-feed');
6555
7473
  const loadingEl = Object.assign(document.createElement('div'), { id: 'odt-chat-loading', style: 'text-align:center;padding:24px 0;font-size:11px;opacity:0.5;color:var(--text-mid)' });
6556
7474
  loadingEl.textContent = 'Loading run events…';
6557
7475
  if (feed2) feed2.appendChild(loadingEl);
7476
+ // Guard SSE handler: prevent it from appending to the feed while fetch is in-flight.
7477
+ // Without this, SSE events get appended immediately AND replayed by the bulk render
7478
+ // below (since they were also pushed to sess.events) → duplicate messages.
7479
+ const _preLoadEvents = (sess.events || []).slice();
7480
+ sess._loading = true;
6558
7481
  try {
6559
7482
  const dirParam = (typeof DIR !== 'undefined' && DIR) ? '?dir='+encodeURIComponent(DIR) : '';
6560
7483
  const evs = await apiFetch('/api/org/'+encodeURIComponent(_v2SelOrg)+'/runs/'+encodeURIComponent(id)+dirParam);
6561
- sess.events = Array.isArray(evs) ? evs : [];
7484
+ // Merge: SSE events pushed to sess.events DURING the fetch are not in the API response
7485
+ // (they haven't been flushed to disk yet). Re-attach them so they aren't overwritten.
7486
+ const _sseDuringLoad = (sess.events || []).filter(e => !_preLoadEvents.find(p => p === e));
7487
+ sess.events = Array.isArray(evs) ? evs : _preLoadEvents;
7488
+ _sseDuringLoad.forEach(e => {
7489
+ if (!sess.events.find(s => s.ts === e.ts && s.type === e.type && (s.from||'') === (e.from||''))) sess.events.push(e);
7490
+ });
7491
+ sess._eventsLoaded = true;
7492
+ } catch(_) {
7493
+ // On failure keep whatever was buffered so SSE events aren't lost
6562
7494
  sess._eventsLoaded = true;
6563
- } catch(_) { sess.events = []; sess._eventsLoaded = true; }
7495
+ }
7496
+ sess._loading = false;
7497
+ // Sync back — _odtLoadChatSessions may have replaced the object reference during the await
7498
+ const _runLive = _odtChatSessions.find(s => s.id === id);
7499
+ if (_runLive && _runLive !== sess) { _runLive._eventsLoaded = true; _runLive._loading = false; _runLive.events = sess.events; }
6564
7500
  loadingEl.remove();
7501
+ if (_odtChatCurrentId !== id) return; // user switched sessions during fetch
6565
7502
  }
6566
7503
  // Agent pills from org config roles (authoritative list, not derived from events)
6567
7504
  const orgRoles = Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : [];
6568
7505
  _odtPopulateAgentBar(sess.events || [], orgRoles);
6569
- // No excerpt banner for structured org run sessions
7506
+ // Inject session metadata header for session-based (non-_isOrgRun) sessions so the
7507
+ // user can see prompt, ID, start time, and status without opening a separate tab.
7508
+ // Org run sessions already get a summary header from the dropdown label + run meta.
6570
7509
  const ex = document.getElementById('odt-chat-excerpt');
6571
- if (ex) ex.innerHTML = '';
6572
- (sess.events || []).forEach(ev => _odtAppendEvent(ev, false));
7510
+ if (!sess._isOrgRun) {
7511
+ const _shdrTs = sess.ts || sess.startedAt || 0;
7512
+ const _shdrTsStr = _shdrTs ? new Date(_shdrTs).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
7513
+ const _shdrPrompt = (sess.prompt || '').replace(/^running org:\s*/i, '').slice(0, 100) || id;
7514
+ const _shdrSt = sess.status || 'unknown';
7515
+ const _shdrStCls = _shdrSt === 'running' ? 'color:oklch(65% 0.20 145)' : _shdrSt === 'complete' ? 'color:var(--text-lo)' : 'color:var(--text-xs)';
7516
+ const _shdr = document.createElement('div');
7517
+ _shdr.className = 'cv-session-hdr';
7518
+ _shdr.innerHTML = `<div class="cv-shdr-prompt">${esc(_shdrPrompt)}</div>`+
7519
+ `<div class="cv-shdr-meta">`+
7520
+ `<span class="cv-shdr-id" title="${esc(id)}">${esc(id.slice(0,22))}</span>`+
7521
+ `<span class="cv-shdr-sep">·</span>`+
7522
+ `<span style="${_shdrStCls}">${esc(_shdrSt)}</span>`+
7523
+ `<span class="cv-shdr-sep">·</span>`+
7524
+ `<span>${esc(_shdrTsStr)}</span>`+
7525
+ `<span class="cv-shdr-sep">·</span>`+
7526
+ `<span class="cv-shdr-evcount">${(sess.events || []).length} events</span>`+
7527
+ `</div>`;
7528
+ feed.appendChild(_shdr);
7529
+ // Populate excerpt banner to show last activity for historical sessions
7530
+ if (ex) { if ((sess.events || []).length) renderCvExcerptBanner('odt-chat-excerpt', sess.events, sess); else ex.innerHTML = ''; }
7531
+ } else {
7532
+ // Org run sessions: populate excerpt with last activity (mirrors non-_isOrgRun path).
7533
+ // Previous code always cleared this, leaving the banner permanently blank for org runs.
7534
+ if (ex && (sess.events || []).length > 0) renderCvExcerptBanner('odt-chat-excerpt', sess.events, sess);
7535
+ else if (ex) ex.innerHTML = '';
7536
+ }
7537
+ (sess.events || []).forEach(ev => {
7538
+ _odtAppendEvent(ev, false);
7539
+ // Pre-seed dedup set so SSE reconnect replays of these historical events are swallowed
7540
+ // rather than appended again. Without this, a reconnect replays ~50 events from the server
7541
+ // and they all pass _odtChatSeenKeys (which only tracks events that arrived via SSE).
7542
+ const _hk = (ev?.ts||'')+'|'+(ev?.type||'')+'|'+(ev?.runId||ev?.session||'')+'|'+(ev?.from||ev?.role||ev?.agent||'')+'|'+(ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
7543
+ _odtChatSeenKeys.add(_hk);
7544
+ });
7545
+ // For [S] sessions (mastermind wrappers that triggered an org run), show a link card to the
7546
+ // corresponding [R] run so users can navigate there directly. The runId is always
7547
+ // "run-" + same timestamp suffix as the session ID (e.g. mm-20260617T235959 → run-20260617T235959).
7548
+ if (!sess._isOrgRun) {
7549
+ const _linkedRunId = 'run-' + (sess.id || '').replace(/^mm-/, '');
7550
+ const _linkedRun = _odtChatSessions.find(s => s._isOrgRun && s.id === _linkedRunId);
7551
+ if (_linkedRun) {
7552
+ const _linkEl = document.createElement('div');
7553
+ _linkEl.className = 'cv-msg cv-sys';
7554
+ const _lrGoal = (_linkedRun.prompt || '').slice(0, 60) || _linkedRunId;
7555
+ _linkEl.innerHTML = `<div class="cv-bub" style="cursor:pointer;border-left:2px solid var(--accent);background:oklch(15% 0.05 220)">`+
7556
+ `<span class="cv-etype" style="color:var(--accent)">RUN</span>`+
7557
+ `<span class="cv-text">→ ${esc(_lrGoal)}${_lrGoal.length >= 60 ? '…' : ''}</span>`+
7558
+ `<span class="cv-ts" style="color:var(--accent)">[${esc(_linkedRun.status||'?')}]</span></div>`;
7559
+ _linkEl.title = 'Click to view the org run triggered by this session';
7560
+ _linkEl.onclick = () => {
7561
+ const _lSel = document.getElementById('odt-chat-sel');
7562
+ if (_lSel) { _lSel.value = _linkedRunId; odtChatSelectSession(_linkedRunId); }
7563
+ };
7564
+ feed.appendChild(_linkEl);
7565
+ if (emptyEl) emptyEl.style.display = 'none';
7566
+ }
7567
+ }
7568
+ // If nothing was rendered (empty run or all events unknown type), show a clear empty state
7569
+ // instead of leaving the user with a blank white feed and no feedback.
7570
+ if (!feed.querySelector('.cv-msg') && emptyEl) {
7571
+ emptyEl.style.display = 'block';
7572
+ emptyEl.textContent = (sess.events && sess.events.length)
7573
+ ? 'No displayable events.'
7574
+ : 'No events yet.';
7575
+ }
6573
7576
  feed.scrollTop = feed.scrollHeight;
6574
7577
  };
6575
7578
 
@@ -6585,20 +7588,33 @@ function _odtPopulateAgentBar(events, orgRoles) {
6585
7588
  (events || []).forEach(ev => {
6586
7589
  if (ev.type === 'org:agent:online' && ev.role) agentSet.add(ev.role);
6587
7590
  else if (ev.type === 'org:comms') {
6588
- if (ev.from && ev.from.length < 40) agentSet.add(ev.from);
7591
+ // Support both ev.from (new format) and ev.role (old format) as sender
7592
+ const _sf = ev.from || ev.role || '';
7593
+ if (_sf && _sf.length < 40) agentSet.add(_sf);
6589
7594
  if (ev.to && ev.to !== 'all' && ev.to.length < 40) agentSet.add(ev.to);
6590
7595
  }
6591
7596
  });
6592
7597
  }
7598
+ // Count messages per agent so pills can show activity level
7599
+ const msgCount = {};
7600
+ (events || []).forEach(ev => {
7601
+ if (ev.type === 'org:comms' || ev.type === 'agent:message') {
7602
+ const sender = ev.from || ev.role || ev.agent || '';
7603
+ if (sender) msgCount[sender] = (msgCount[sender] || 0) + 1;
7604
+ }
7605
+ });
6593
7606
  const agents = [...agentSet].sort();
6594
7607
  if (!agents.length) { bar.style.display = 'none'; return; }
6595
7608
  bar.style.display = 'flex';
6596
7609
  bar.innerHTML = '<span id="odt-chat-agent-lbl">AGENT</span>';
6597
7610
  ['all', ...agents].forEach(name => {
6598
7611
  const pill = document.createElement('button');
6599
- pill.className = 'odt-agent-pill' + (_odtChatCurrentAgent === name ? ' active' : '');
6600
- pill.textContent = name === 'all' ? 'ALL' : name;
7612
+ const cnt = name === 'all' ? null : (msgCount[name] || 0);
7613
+ const active = _odtChatCurrentAgent === name;
7614
+ pill.className = 'odt-agent-pill' + (active ? ' active' : '') + (cnt === 0 ? ' dim' : '');
7615
+ pill.textContent = name === 'all' ? 'ALL' : (cnt > 0 ? `${name} (${cnt})` : name);
6601
7616
  pill.dataset.agent = name;
7617
+ pill.title = name === 'all' ? 'Show all events' : (cnt > 0 ? `${cnt} message${cnt!==1?'s':''} from ${name}` : `${name} was online but sent no messages`);
6602
7618
  pill.onclick = () => odtChatSelectAgent(name);
6603
7619
  bar.appendChild(pill);
6604
7620
  });
@@ -6616,11 +7632,31 @@ window.odtChatSelectAgent = function(name) {
6616
7632
  if (!sess) { if (emptyEl) emptyEl.style.display = 'block'; return; }
6617
7633
  const visible = (sess.events || []).filter(ev => _odtChatAgentMatches(ev));
6618
7634
  if (!visible.length) {
6619
- if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = name === 'all' ? 'No events.' : `No events for "${name}"`; }
7635
+ if (emptyEl) { emptyEl.style.display = 'block'; emptyEl.textContent = name === 'all' ? 'No events.' : `${name} was online in this run but sent no messages.`; }
6620
7636
  } else {
6621
7637
  if (emptyEl) emptyEl.style.display = 'none';
6622
7638
  visible.forEach(ev => _odtAppendEvent(ev, false));
6623
7639
  }
7640
+ if (!sess._isOrgRun) {
7641
+ const _linkedRunId = 'run-' + (sess.id || '').replace(/^mm-/, '');
7642
+ const _linkedRun = _odtChatSessions.find(s => s._isOrgRun && s.id === _linkedRunId);
7643
+ if (_linkedRun) {
7644
+ const _linkEl = document.createElement('div');
7645
+ _linkEl.className = 'cv-msg cv-sys';
7646
+ const _lrGoal = (_linkedRun.prompt || '').slice(0, 60) || _linkedRunId;
7647
+ _linkEl.innerHTML = `<div class="cv-bub" style="cursor:pointer;border-left:2px solid var(--accent);background:oklch(15% 0.05 220)">`+
7648
+ `<span class="cv-etype" style="color:var(--accent)">RUN</span>`+
7649
+ `<span class="cv-text">→ ${esc(_lrGoal)}${_lrGoal.length >= 60 ? '…' : ''}</span>`+
7650
+ `<span class="cv-ts" style="color:var(--accent)">[${esc(_linkedRun.status||'?')}]</span></div>`;
7651
+ _linkEl.title = 'Click to view the org run triggered by this session';
7652
+ _linkEl.onclick = () => {
7653
+ const _lSel = document.getElementById('odt-chat-sel');
7654
+ if (_lSel) { _lSel.value = _linkedRunId; odtChatSelectSession(_linkedRunId); }
7655
+ };
7656
+ feed.appendChild(_linkEl);
7657
+ if (emptyEl) emptyEl.style.display = 'none';
7658
+ }
7659
+ }
6624
7660
  feed.scrollTop = feed.scrollHeight;
6625
7661
  };
6626
7662
 
@@ -6628,6 +7664,10 @@ function _odtAppendEvent(ev, animate) {
6628
7664
  if (!_odtChatAgentMatches(ev)) return;
6629
7665
  const feed = document.getElementById('odt-chat-feed');
6630
7666
  if (!feed) return;
7667
+ // Capture scroll position BEFORE appending: only auto-scroll for live events when
7668
+ // the user is already at (or near) the bottom. If they scrolled up to read earlier
7669
+ // messages a new event must not forcibly jump them back to the bottom.
7670
+ const atBottom = !animate || (feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80);
6631
7671
  const emptyEl = document.getElementById('odt-chat-empty');
6632
7672
  if (emptyEl) emptyEl.style.display = 'none';
6633
7673
  const ts = ev.ts ? new Date(ev.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
@@ -6635,22 +7675,24 @@ function _odtAppendEvent(ev, animate) {
6635
7675
  if (ev.type === 'run:start') {
6636
7676
  el = mkCVSys('▶ Run started' + (ev.goal ? ': ' + esc(ev.goal.slice(0,80)) : '') + (ev.bossRole ? ' · boss: ' + esc(ev.bossRole) : ''), ts);
6637
7677
  } else if (ev.type === 'run:cycle:complete') {
6638
- el = mkCVSys('◎ Cycle complete' + (ev.pending != null ? ' · ' + ev.pending + ' tasks pending' : ''), ts);
7678
+ el = mkCVSys('◎ Cycle complete' + (ev.pending != null ? ' · ' + esc(String(ev.pending)) + ' tasks pending' : ''), ts);
6639
7679
  } else if (ev.type === 'run:complete') {
6640
7680
  el = mkCVSys('■ Run complete' + (ev.status ? ' [' + esc(ev.status) + ']' : ''), ts);
6641
7681
  } else if (ev.type === 'org:comms') {
6642
- if (ev.to && ev.to !== 'all' && ev.from) el = mkCVIntercom(ev.from || 'boss', ev.to, ev.msg || '', ts);
6643
- else el = mkCVAgent(ev.from || 'boss', ev.msg || '', ts, 'org:comms');
7682
+ // Some older runs use ev.role instead of ev.from normalise before rendering
7683
+ const _commsFrom = ev.from || ev.role || 'boss';
7684
+ if (ev.to && ev.to !== 'all') el = mkCVIntercom(_commsFrom, ev.to, ev.msg || '', ts);
7685
+ else el = mkCVAgent(_commsFrom, ev.msg || '', ts, 'org:comms');
6644
7686
  } else if (ev.type === 'org:agent:online') {
6645
7687
  el = mkCVSys('◉ ' + esc(ev.title || ev.role || '?') + ' online', ts);
6646
7688
  } else if (ev.type === 'org:checkpoint') {
6647
- el = mkCVAgent('boss', ev.progress || '', ts, 'org:checkpoint');
7689
+ el = mkCVAgent('boss', ev.summary || ev.progress || ev.msg || '', ts, 'org:checkpoint');
6648
7690
  } else if (ev.type === 'org:start' || ev.type === 'org:complete') {
6649
7691
  el = mkCVSys(ev.type === 'org:start' ? '▶ Org started' : '■ Org complete', ts);
6650
7692
  } else if (ev.type === 'agent:spawn') {
6651
7693
  el = mkCVSpawn(ev.from || 'orchestrator', ev.to || '?', ev.task || ev.briefing || '', ts);
6652
7694
  } else if (ev.type === 'agent:result') {
6653
- el = mkCVResult(ev.agent || '?', ev.msg || '', ts);
7695
+ el = mkCVResult(ev.agent || ev.from || '?', ev.result || ev.msg || ev.message || ev.summary || '', ts);
6654
7696
  } else if (ev.type === 'file:write') {
6655
7697
  el = mkCVFileCard(ev.name || ev.path || '?', ev.path || '', ev.size || 0, ts);
6656
7698
  } else if (ev.type === 'intercom') {
@@ -6669,12 +7711,32 @@ function _odtAppendEvent(ev, animate) {
6669
7711
  el = mkCVSys('Loop: ' + esc(ev.command || ''), ts);
6670
7712
  } else if (ev.type === 'loop:complete') {
6671
7713
  el = mkCVSys('Loop done: ' + esc(ev.command || '') + (ev.ranReps ? ' (' + ev.ranReps + ' runs)' : ''), ts);
7714
+ } else if (ev.type === 'loop:tick') {
7715
+ const _trep = ev.rep ?? ev.fromRep;
7716
+ const _tnext = ev.nextRep ?? ev.toRep;
7717
+ const tickLabel = _trep != null
7718
+ ? 'rep ' + esc(String(_trep)) + ' done' + (_tnext != null ? ' → rep ' + esc(String(_tnext)) + (ev.wait != null ? ' in ' + esc(String(ev.wait)) + 's' : '') : '')
7719
+ : esc(ev.command || ev.loopId || '');
7720
+ el = mkCVSys('◷ ' + tickLabel, ts);
7721
+ el.dataset.evType = 'loop:tick';
7722
+ // Collapse: replace the previous loop:tick if it's the last element — prevents the
7723
+ // feed from flooding with one entry per rep in long-running loops (e.g. --repeat 9999).
7724
+ if (feed.lastElementChild?.dataset?.evType === 'loop:tick') feed.lastElementChild.remove();
7725
+ } else if (ev.type === 'loop:hil' || ev.type === 'loop:hil:waiting') {
7726
+ el = mkCVSys('⚠ Loop HIL: ' + esc(ev.command || ev.loopId || ''), ts);
7727
+ } else if (ev.type === 'loop:hil:resolved') {
7728
+ el = mkCVSys('✓ Loop HIL resolved: ' + esc(ev.loopId || ''), ts);
7729
+ } else if (ev.type === 'org:error') {
7730
+ el = mkCVSys('⚠ Error: ' + esc(ev.msg || ev.error || ev.message || ''), ts);
7731
+ el.classList.add('cv-err');
7732
+ } else if (ev.type === 'org:agent:offline') {
7733
+ el = mkCVSys('◌ ' + esc(ev.title || ev.role || '?') + ' offline', ts);
6672
7734
  } else {
6673
- return; // skip unknown event types silently
7735
+ el = mkCVSys(esc(ev.type || 'event'), ts);
6674
7736
  }
6675
7737
  if (animate) el.classList.add('cv-new');
6676
7738
  feed.appendChild(el);
6677
- feed.scrollTop = feed.scrollHeight;
7739
+ if (atBottom) feed.scrollTop = feed.scrollHeight;
6678
7740
  }
6679
7741
 
6680
7742
  function _odtConnectChatSSE() {
@@ -6689,46 +7751,98 @@ function _odtConnectChatSSE() {
6689
7751
  _odtChatSseSource.onerror = () => {
6690
7752
  dot?.classList.remove('on');
6691
7753
  if (lbl) lbl.textContent = 'OFFLINE';
7754
+ // Close before nulling: EventSource has built-in auto-retry, so without close()
7755
+ // the browser keeps reconnecting the old source. We'd then create a second one
7756
+ // via setTimeout → two SSE connections delivering the same events simultaneously.
7757
+ const _src = _odtChatSseSource;
6692
7758
  _odtChatSseSource = null;
7759
+ if (_src) _src.close();
6693
7760
  setTimeout(_odtConnectChatSSE, 5000);
6694
7761
  };
6695
7762
  }
6696
7763
 
6697
7764
  function _odtHandleLiveEvent(ev) {
7765
+ // Deduplicate SSE reconnect replays (server replays last 50 events on every reconnect)
7766
+ const _odtDedupKey = (ev?.ts||'') + '|' + (ev?.type||'') + '|' + (ev?.runId||ev?.session||'') + '|' + (ev?.from||ev?.role||ev?.agent||'') + '|' + (ev?.result||ev?.msg||ev?.message||ev?.summary||'').slice(0,20);
7767
+ if (_odtChatSeenKeys.has(_odtDedupKey)) return;
7768
+ _odtChatSeenKeys.add(_odtDedupKey);
7769
+ // Cap the dedup Set to prevent unbounded memory growth in long-running sessions.
7770
+ // Server replays only the last ~50 events on reconnect, so pruning entries older
7771
+ // than 1000 events never risks a false-pass through the dedup check.
7772
+ if (_odtChatSeenKeys.size > 2000) {
7773
+ const _arr = [..._odtChatSeenKeys];
7774
+ _odtChatSeenKeys = new Set(_arr.slice(1000));
7775
+ }
6698
7776
  // Route org run events (have runId + org) to the active run session
6699
7777
  if (ev?.org && ev?.runId && ev.org === _v2SelOrg) {
6700
7778
  let runSess = _odtChatSessions.find(s => s.id === ev.runId);
6701
- if (!runSess && ev.type === 'run:start') {
6702
- runSess = { id: ev.runId, ts: ev.ts || Date.now(), prompt: ev.goal || '', status: 'running', _isOrgRun: true, _eventsLoaded: true, _runMeta: { cycleCount: 0, eventCount: 0 }, events: [ev] };
7779
+ if (!runSess) {
7780
+ // Create a session placeholder for any unknown runId so SSE events aren't dropped
7781
+ // while _odtLoadChatSessions fetch is still in-flight (opening Chat tab mid-run).
7782
+ // For run:start: full setup + auto-switch. For other types: minimal placeholder
7783
+ // with _eventsLoaded:false so lazy-load fetches full history when selected.
7784
+ runSess = { id: ev.runId, ts: ev.ts || Date.now(), prompt: ev.goal || '', status: 'running', _isOrgRun: true, _eventsLoaded: ev.type === 'run:start', _runMeta: { cycleCount: 0, eventCount: 1 }, events: [ev] };
6703
7785
  _odtChatSessions.unshift(runSess);
7786
+ // Always update the dropdown so the run is selectable even when opened mid-run
6704
7787
  _odtPopulateChatSel();
6705
- if (!_odtChatCurrentId && _v2OrgTab === 'chat') {
6706
- const sel = document.getElementById('odt-chat-sel');
6707
- if (sel) { sel.value = ev.runId; odtChatSelectSession(ev.runId); }
7788
+ if (ev.type === 'run:start') {
7789
+ // After _odtPopulateChatSel above rebuilt groups, check if the new run joined
7790
+ // the currently-selected group. If so, keep the group selected. Otherwise switch.
7791
+ const _newGrpId = _odtRunGroupMap[ev.runId];
7792
+ const _alreadySelected = _newGrpId && _odtChatCurrentId === _newGrpId;
7793
+ if (!_alreadySelected) {
7794
+ const _rsSel = document.getElementById('odt-chat-sel');
7795
+ // Switch to the run's group if it has one, otherwise to the individual run
7796
+ const _switchId = _newGrpId || ev.runId;
7797
+ if (_rsSel) { _rsSel.value = _switchId; odtChatSelectSession(_switchId); }
7798
+ }
6708
7799
  }
6709
7800
  } else if (runSess) {
6710
7801
  (runSess.events = runSess.events || []).push(ev);
6711
7802
  if (runSess._runMeta) runSess._runMeta.eventCount = (runSess._runMeta.eventCount || 0) + 1;
6712
- if (ev.type === 'run:cycle:complete') { if (runSess._runMeta) runSess._runMeta.cycleCount = (runSess._runMeta.cycleCount || 0) + 1; }
7803
+ if (ev.type === 'run:cycle:complete' || ev.type === 'org:checkpoint') { if (runSess._runMeta) runSess._runMeta.cycleCount = (runSess._runMeta.cycleCount || 0) + 1; _odtPopulateChatSel(); }
7804
+ // Backfill prompt from first boss directive when run:start had no goal field
7805
+ if (!runSess.prompt && ev.type === 'org:comms' && (ev.from === 'boss' || ev.role === 'boss') && ev.msg) {
7806
+ runSess.prompt = ev.msg.slice(0, 80);
7807
+ _odtPopulateChatSel();
7808
+ }
6713
7809
  if (ev.type === 'run:complete' || ev.type === 'org:complete') { runSess.status = 'complete'; _odtPopulateChatSel(); }
6714
- if (_odtChatCurrentId === ev.runId && _v2OrgTab === 'chat') {
7810
+ // Check if this run's group (or the run itself) is currently selected
7811
+ const _evRunGrpId = _odtRunGroupMap[ev.runId];
7812
+ const _isSelRun = _odtChatCurrentId === ev.runId;
7813
+ const _isSelRunGrp = _evRunGrpId && _odtChatCurrentId === _evRunGrpId;
7814
+ if ((_isSelRun || _isSelRunGrp) && !runSess._loading) {
7815
+ const hasNewRunAgent = (ev.role && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.role)}"]`))
7816
+ || (ev.from && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.from)}"]`))
7817
+ || (ev.to && ev.to !== 'all' && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.to)}"]`));
7818
+ if (hasNewRunAgent) _odtPopulateAgentBar(runSess.events, Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : []);
6715
7819
  _odtAppendEvent(ev, true);
7820
+ renderCvExcerptBanner('odt-chat-excerpt', runSess.events, runSess);
7821
+ const _rEvCnt = document.querySelector('#odt-chat-feed .cv-session-hdr .cv-shdr-evcount');
7822
+ if (_rEvCnt) {
7823
+ const _rgrpEvs = _isSelRunGrp ? _odtGetMergedRunGroupEvents(_odtRunGroups[_evRunGrpId]) : runSess.events;
7824
+ _rEvCnt.textContent = _rgrpEvs.length + ' events';
7825
+ }
6716
7826
  }
6717
7827
  }
6718
7828
  return;
6719
7829
  }
6720
7830
 
6721
7831
  if (!ev?.session) return;
6722
- const isOrgSess = _odtOrgSessionMatch({ id: ev.session, prompt: ev.prompt || '', events: [ev] });
6723
- if (!isOrgSess) return;
6724
-
7832
+ // Find existing session first if already in our list it's been validated as belonging to
7833
+ // this org, so skip the prompt-based org check that would falsely drop events whose
7834
+ // individual payloads don't carry an ev.org field (e.g. intercom, agent:message).
6725
7835
  let sess = _odtChatSessions.find(s => s.id === ev.session);
7836
+ if (!sess) {
7837
+ const isOrgSess = _odtOrgSessionMatch({ id: ev.session, prompt: ev.prompt || '', events: [ev] });
7838
+ if (!isOrgSess) return;
7839
+ }
6726
7840
  if (!sess && ev.type === 'session:start') {
6727
7841
  sess = { id: ev.session, ts: ev.ts || Date.now(), prompt: ev.prompt || '', status: 'running', events: [ev] };
6728
7842
  _odtChatSessions.unshift(sess);
6729
7843
  _odtPopulateChatSel();
6730
- // Auto-select if nothing selected
6731
- if (!_odtChatCurrentId && _v2OrgTab === 'chat') {
7844
+ // Auto-select if nothing selected (tab-guard removed: feed is in DOM regardless of active tab)
7845
+ if (!_odtChatCurrentId) {
6732
7846
  const sel = document.getElementById('odt-chat-sel');
6733
7847
  if (sel) { sel.value = ev.session; odtChatSelectSession(ev.session); }
6734
7848
  }
@@ -6736,14 +7850,23 @@ function _odtHandleLiveEvent(ev) {
6736
7850
  }
6737
7851
  if (sess) {
6738
7852
  (sess.events = sess.events || []).push(ev);
6739
- if (ev.type === 'session:complete') { sess.status = ev.status || 'complete'; _odtPopulateChatSel(); }
6740
- if (_odtChatCurrentId === ev.session && _v2OrgTab === 'chat') {
7853
+ if (ev.type === 'session:complete') { sess.status = 'complete'; _odtPopulateChatSel(); }
7854
+ // Determine if a group containing this session is currently selected
7855
+ const _evGrpId = _odtSessionGroupMap[ev.session];
7856
+ const _isCurrentSess = _odtChatCurrentId === ev.session;
7857
+ const _isCurrentGrp = _evGrpId && _odtChatCurrentId === _evGrpId;
7858
+ const _notLoading = !sess._loading && (!_evGrpId || !(_odtSessionGroups[_evGrpId]?.sessions || []).some(s => s._loading));
7859
+ if ((_isCurrentSess || _isCurrentGrp) && _notLoading) {
7860
+ // sess._loading is true while odtChatSelectSession's lazy-load fetch is in-flight.
6741
7861
  const hasNewAgent = (ev.agent && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.agent)}"]`))
6742
7862
  || (ev.from && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.from)}"]`))
6743
7863
  || (ev.to && !document.querySelector(`#odt-chat-agent-bar [data-agent="${CSS.escape(ev.to)}"]`));
6744
7864
  if (hasNewAgent) _odtPopulateAgentBar(sess.events, Array.isArray(_v2OrgData?.roles) ? _v2OrgData.roles : []);
6745
7865
  if (!sess._isJsonl) renderCvExcerptBanner('odt-chat-excerpt', sess.events, sess);
6746
7866
  _odtAppendEvent(ev, true);
7867
+ // Keep event count in session header current (frozen at load time otherwise)
7868
+ const _sEvCnt = document.querySelector('#odt-chat-feed .cv-session-hdr .cv-shdr-evcount');
7869
+ if (_sEvCnt) _sEvCnt.textContent = ((_isCurrentGrp ? _odtGetMergedGroupEvents(_odtSessionGroups[_evGrpId]) : sess.events).length) + ' events';
6747
7870
  }
6748
7871
  }
6749
7872
  }
@@ -6792,8 +7915,14 @@ function v2RenderOrgConfig() {
6792
7915
  async function v2SaveOrgConfig() {
6793
7916
  if (!_v2SelOrg || !_v2OrgData) return;
6794
7917
  const g = id => document.getElementById(id);
7918
+ // Only include org config fields — exclude runtime data (state, goals, routines, approvals,
7919
+ // running, tasks, config nested copy) that would corrupt the org .json file.
7920
+ const _RUNTIME_KEYS = new Set(['config','state','goals','routines','approvals','running','tasks']);
7921
+ const _baseConfig = Object.fromEntries(
7922
+ Object.entries(_v2OrgData).filter(([k]) => !k.startsWith('_') && !_RUNTIME_KEYS.has(k))
7923
+ );
6795
7924
  const cfg = {
6796
- ..._v2OrgData,
7925
+ ..._baseConfig,
6797
7926
  status: (g('oc-status')?.value) || _v2OrgData.status,
6798
7927
  goal: (g('oc-goal')?.value) || _v2OrgData.goal,
6799
7928
  mode: (g('oc-mode')?.value) || _v2OrgData.mode,
@@ -6833,11 +7962,13 @@ async function v2SaveOrgConfig() {
6833
7962
  async function v2RenderOrgFiles() {
6834
7963
  const el = document.getElementById('odt-files');
6835
7964
  if (!el || !_v2SelOrg) return;
7965
+ const _rendOrg = _v2SelOrg;
6836
7966
  el.innerHTML = '<div style="padding:20px;color:var(--text-lo)">Loading files...</div>';
6837
7967
  try {
6838
- const enc = encodeURIComponent(_v2SelOrg);
7968
+ const enc = encodeURIComponent(_rendOrg);
6839
7969
  const qs = (typeof DIR !== 'undefined' && DIR) ? '?dir='+encodeURIComponent(DIR) : '';
6840
7970
  const files = await fetch('/api/org/'+enc+'/files'+qs).then(r => r.ok ? r.json() : []).catch(() => []);
7971
+ if (_v2SelOrg !== _rendOrg) return;
6841
7972
  if (!files.length) { el.innerHTML = '<div style="padding:20px;color:var(--text-lo)">No files found.</div>'; return; }
6842
7973
  const fmtSize = b => b < 1024 ? b+'B' : b < 1048576 ? (b/1024).toFixed(1)+'K' : (b/1048576).toFixed(1)+'M';
6843
7974
  const fmtTime = s => { const d = new Date(s); return d.toLocaleDateString([],{month:'short',day:'numeric'})+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); };
@@ -6966,10 +8097,12 @@ function _ofSaveFile(defaultName) {
6966
8097
  async function v2RenderOrgWorkspaces() {
6967
8098
  const el = document.getElementById('odt-workspaces');
6968
8099
  if (!el || !_v2SelOrg) return;
8100
+ const _rendOrg = _v2SelOrg;
6969
8101
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6970
8102
  try {
6971
- const _enc = encodeURIComponent(_v2SelOrg);
8103
+ const _enc = encodeURIComponent(_rendOrg);
6972
8104
  const data = await fetch(`/api/org/${_enc}/workspaces${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
8105
+ if (_v2SelOrg !== _rendOrg) return;
6973
8106
  const ws = Array.isArray(data) ? data : (data.workspaces || []);
6974
8107
  if (!ws.length) { el.innerHTML = '<div class="empty">No workspaces configured</div>'; return; }
6975
8108
  el.innerHTML = ws.map(w => `<div style="padding:10px 0;border-bottom:1px solid var(--border)">
@@ -6987,10 +8120,12 @@ async function v2RenderOrgWorkspaces() {
6987
8120
  async function v2RenderOrgInvites() {
6988
8121
  const el = document.getElementById('odt-invites');
6989
8122
  if (!el || !_v2SelOrg) return;
8123
+ const _rendOrg = _v2SelOrg;
6990
8124
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
6991
8125
  try {
6992
- const _enc = encodeURIComponent(_v2SelOrg);
8126
+ const _enc = encodeURIComponent(_rendOrg);
6993
8127
  const data = await fetch(`/api/org/${_enc}/invites${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
8128
+ if (_v2SelOrg !== _rendOrg) return;
6994
8129
  const invites = Array.isArray(data) ? data : (data.invites || []);
6995
8130
  if (!invites.length) { el.innerHTML = '<div class="empty">No active invite tokens</div>'; return; }
6996
8131
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -7019,12 +8154,12 @@ function v2RenderOrgAgentsFull() {
7019
8154
  </tr></thead>
7020
8155
  <tbody>${agents.map(a => `<tr style="border-top:1px solid var(--border)">
7021
8156
  <td style="padding:5px 8px;color:var(--text-hi);font-family:var(--mono);font-size:11px">${esc((a.id||'—').toString().slice(0,12))}</td>
7022
- <td style="padding:5px 8px;color:var(--text-hi)">${esc(a.type||a.title||'—')}</td>
7023
- <td style="padding:5px 8px;color:var(--text-lo)">${esc(a.adapter||'—')}</td>
8157
+ <td style="padding:5px 8px;color:var(--text-hi)">${esc(a.adapterType||a.type||a.title||'—')}</td>
8158
+ <td style="padding:5px 8px;color:var(--text-lo)">${esc(a.adapterModel||a.adapter||'—')}</td>
7024
8159
  <td style="padding:5px 8px"><span class="ss-pill ${(a.status==='running'||a.running)?'on':''}">${esc(a.status||'idle')}</span></td>
7025
8160
  <td style="padding:5px 8px;color:var(--text-lo)">${a.lastHeartbeat ? relTime(a.lastHeartbeat) : '—'}</td>
7026
- <td style="padding:5px 8px;color:var(--text-lo)">${a.tokensIn != null ? Number(a.tokensIn).toLocaleString() : '—'}</td>
7027
- <td style="padding:5px 8px;color:var(--text-lo)">${a.tokensOut != null ? Number(a.tokensOut).toLocaleString() : '—'}</td>
8161
+ <td style="padding:5px 8px;color:var(--text-lo)">${(a.tokensIn ?? a.tokens_in) != null ? Number(a.tokensIn ?? a.tokens_in).toLocaleString() : '—'}</td>
8162
+ <td style="padding:5px 8px;color:var(--text-lo)">${(a.tokensOut ?? a.tokens_out) != null ? Number(a.tokensOut ?? a.tokens_out).toLocaleString() : '—'}</td>
7028
8163
  </tr>`).join('')}</tbody>
7029
8164
  </table>`;
7030
8165
  }
@@ -7033,10 +8168,12 @@ function v2RenderOrgAgentsFull() {
7033
8168
  async function v2RenderOrgEnvironments() {
7034
8169
  const el = document.getElementById('odt-environments');
7035
8170
  if (!el || !_v2SelOrg) return;
8171
+ const _rendOrg = _v2SelOrg;
7036
8172
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
7037
8173
  try {
7038
- const _enc = encodeURIComponent(_v2SelOrg);
8174
+ const _enc = encodeURIComponent(_rendOrg);
7039
8175
  const data = await fetch(`/api/org/${_enc}/environments${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
8176
+ if (_v2SelOrg !== _rendOrg) return;
7040
8177
  const envs = Array.isArray(data) ? data : (data.environments || []);
7041
8178
  if (!envs.length) { el.innerHTML = '<div class="empty">No environments configured</div>'; return; }
7042
8179
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -7090,7 +8227,7 @@ function v2RenderOrgIssuesFull() {
7090
8227
  <td style="padding:5px 4px">${PRIORITY[i.priority]||'·'}</td>
7091
8228
  <td style="padding:5px 8px;color:var(--text-hi);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((i.title||i.description||'—').slice(0,80))}</td>
7092
8229
  <td style="padding:5px 8px;color:var(--text-lo)">${esc(i.assignee||'—')}</td>
7093
- <td style="padding:5px 8px"><span class="ss-pill ${i.status==='done'?'on':i.status==='blocked'?'warn':''}">${esc(i.status||'open')}</span></td>
8230
+ <td style="padding:5px 8px"><span class="ss-pill ${i.status==='done'||i.status==='closed'||i.status==='resolved'?'on':(i.status==='blocked'||i.status==='in_progress')?'warn':''}">${esc(i.status||'open')}</span></td>
7094
8231
  <td style="padding:5px 8px;color:var(--text-xs);font-family:var(--mono);font-size:11px">${relTime(i.updated_at||i.ts)}</td>
7095
8232
  </tr>`).join('')}</tbody>
7096
8233
  </table>`;
@@ -7100,10 +8237,12 @@ function v2RenderOrgIssuesFull() {
7100
8237
  async function v2RenderOrgJoinRequests() {
7101
8238
  const el = document.getElementById('odt-join-requests');
7102
8239
  if (!el || !_v2SelOrg) return;
8240
+ const _rendOrg = _v2SelOrg;
7103
8241
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
7104
8242
  try {
7105
- const _enc = encodeURIComponent(_v2SelOrg);
8243
+ const _enc = encodeURIComponent(_rendOrg);
7106
8244
  const data = await fetch(`/api/org/${_enc}/join-requests${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
8245
+ if (_v2SelOrg !== _rendOrg) return;
7107
8246
  const reqs = Array.isArray(data) ? data : (data.joinRequests || data.join_requests || []);
7108
8247
  if (!reqs.length) { el.innerHTML = '<div class="empty">No pending join requests</div>'; return; }
7109
8248
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -7124,10 +8263,12 @@ async function v2RenderOrgJoinRequests() {
7124
8263
  async function v2RenderOrgThreads() {
7125
8264
  const el = document.getElementById('odt-threads');
7126
8265
  if (!el || !_v2SelOrg) return;
8266
+ const _rendOrg = _v2SelOrg;
7127
8267
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
7128
8268
  try {
7129
- const _enc = encodeURIComponent(_v2SelOrg);
8269
+ const _enc = encodeURIComponent(_rendOrg);
7130
8270
  const data = await fetch(`/api/org/${_enc}/threads${DIR?'?dir='+encodeURIComponent(DIR):''}`).then(r=>r.ok?r.json():[]).catch(()=>[]);
8271
+ if (_v2SelOrg !== _rendOrg) return;
7131
8272
  const threads = Array.isArray(data) ? data : (data.threads || []);
7132
8273
  if (!threads.length) { el.innerHTML = '<div class="empty">No threads found</div>'; return; }
7133
8274
  el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:12px">
@@ -7149,28 +8290,123 @@ async function v2RenderOrgThreads() {
7149
8290
  window.v2StopOrg = async function() {
7150
8291
  if (!_v2SelOrg) return;
7151
8292
  const stopped = _v2SelOrg;
7152
- try { await fetch(`/api/orgs/${encodeURIComponent(stopped)}/stop`, {method:'POST'}); } catch(_) {}
8293
+ try { await fetch(`/api/orgs/${encodeURIComponent(stopped)}/stop${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`, {method:'POST'}); } catch(_) {}
7153
8294
  setTimeout(async () => { await renderOrgs(); if (_v2SelOrg === stopped) v2SelectOrg(stopped); }, 600);
7154
8295
  };
7155
8296
 
7156
- window.v2ShowCopyOrgDialog = function() {
8297
+ window.v2DeleteOrg = async function() {
8298
+ if (!_v2SelOrg) return;
8299
+ const name = _v2SelOrg;
8300
+ if (!confirm(`Delete org "${name}"?\n\nThis removes the config and all associated data files. This cannot be undone.`)) return;
8301
+ const btn = document.getElementById('org-delete-btn');
8302
+ if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
8303
+ try {
8304
+ const r = await fetch(`/api/orgs/${encodeURIComponent(name)}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`, { method: 'DELETE' });
8305
+ if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || 'HTTP ' + r.status); }
8306
+ showToast('Deleted', `Org "${name}" deleted`, 'ok');
8307
+ _v2SelOrg = null;
8308
+ const inner = document.getElementById('org-detail-inner');
8309
+ if (inner) inner.style.display = 'none';
8310
+ const noSel = document.querySelector('.orgs-no-sel');
8311
+ if (noSel) noSel.style.display = '';
8312
+ await renderOrgs();
8313
+ } catch(e) {
8314
+ showToast('Error', String(e.message || e), 'err');
8315
+ if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
8316
+ }
8317
+ };
8318
+
8319
+ window.v2ShowCopyOrgDialog = async function() {
7157
8320
  const d = document.getElementById('org-copy-dialog');
7158
- if (d) { d.style.display = 'flex'; document.getElementById('org-copy-dest')?.focus(); }
8321
+ if (!d) return;
8322
+ d.style.display = 'flex';
8323
+ document.getElementById('org-copy-dest').value = '';
8324
+ document.getElementById('org-copy-status').textContent = '';
8325
+ document.getElementById('org-copy-confirm-btn').disabled = false;
8326
+ const listEl = document.getElementById('org-copy-proj-list');
8327
+ listEl.innerHTML = '<div style="padding:8px 10px;font-size:11px;color:var(--text-lo)">Loading projects…</div>';
8328
+ try {
8329
+ const data = await apiFetch('/api/projects');
8330
+ const projects = (data?.projects || []);
8331
+ if (!projects.length) { listEl.innerHTML = '<div style="padding:8px 10px;font-size:11px;color:var(--text-lo)">No projects found</div>'; }
8332
+ else {
8333
+ listEl.innerHTML = projects.map(p => {
8334
+ const label = p.name || p.slug || p.path?.split('/').pop() || p.path;
8335
+ const shortP = (p.path || '').replace(/\/Users\/[^/]+\//, '~/');
8336
+ const isCurrent = p.path === (window._orgDir || '');
8337
+ return `<div class="ocp-row${isCurrent ? ' ocp-sel' : ''}" title="${esc(p.path)}" onclick="document.querySelectorAll('.ocp-row').forEach(r=>r.classList.remove('ocp-sel'));this.classList.add('ocp-sel');document.getElementById('org-copy-dest').value='${esc(p.path).replace(/'/g,"\\'")}'" style="padding:7px 10px;font-size:12px;font-family:var(--mono);cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:baseline">
8338
+ <span style="color:var(--text-hi)">${esc(label)}</span>
8339
+ <span style="color:var(--text-lo);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(shortP)}</span>
8340
+ </div>`;
8341
+ }).join('');
8342
+ }
8343
+ } catch(e) { listEl.innerHTML = '<div style="padding:8px;font-size:11px;color:var(--text-lo)">Could not load projects</div>'; }
8344
+ setTimeout(() => document.getElementById('org-copy-dest')?.focus(), 60);
7159
8345
  };
7160
8346
  window.v2DoCopyOrg = async function() {
7161
8347
  const dest = document.getElementById('org-copy-dest')?.value.trim();
7162
- if (!dest) { showToast('Required', 'Enter destination path', 'warn'); return; }
8348
+ const statusEl = document.getElementById('org-copy-status');
8349
+ const confirmBtn = document.getElementById('org-copy-confirm-btn');
8350
+ if (!dest) { if (statusEl) { statusEl.textContent = 'Enter or select a destination'; statusEl.style.color = 'var(--red)'; } return; }
7163
8351
  if (!_v2SelOrg) return;
8352
+ confirmBtn.disabled = true;
8353
+ if (statusEl) { statusEl.textContent = 'Copying…'; statusEl.style.color = 'var(--text-lo)'; }
7164
8354
  try {
7165
- const r = await fetch('/api/org/' + enc(_v2SelOrg) + '/copy?dir=' + enc(DIR), {
8355
+ const r = await fetch('/api/orgs/' + enc(_v2SelOrg) + '/copy?dir=' + enc(DIR), {
7166
8356
  method: 'POST', headers: { 'Content-Type': 'application/json' },
7167
8357
  body: JSON.stringify({ destination: dest }),
7168
8358
  });
7169
- if (!r.ok) throw new Error('HTTP ' + r.status);
7170
- showToast('Copied', `Org "${_v2SelOrg}" copied to ${dest}`, 'ok');
8359
+ const data = await r.json().catch(() => ({}));
8360
+ if (!r.ok) throw new Error(data.error || 'HTTP ' + r.status);
8361
+ showToast('Copied', `"${_v2SelOrg}" → ${dest}`, 'ok');
7171
8362
  document.getElementById('org-copy-dialog').style.display = 'none';
7172
- } catch (e) { showToast('Error', e.message, 'err'); }
8363
+ } catch (e) {
8364
+ if (statusEl) { statusEl.textContent = e.message; statusEl.style.color = 'var(--red)'; }
8365
+ confirmBtn.disabled = false;
8366
+ }
7173
8367
  };
8368
+ window.v2ExportOrg = async function() {
8369
+ if (!_v2SelOrg) return;
8370
+ try {
8371
+ const r = await fetch('/api/orgs/' + encodeURIComponent(_v2SelOrg) + (DIR ? '?dir=' + encodeURIComponent(DIR) : ''));
8372
+ if (!r.ok) throw new Error('HTTP ' + r.status);
8373
+ const data = await r.json();
8374
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
8375
+ const url = URL.createObjectURL(blob);
8376
+ const a = document.createElement('a');
8377
+ a.href = url; a.download = _v2SelOrg + '.json'; a.click();
8378
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
8379
+ showToast('Exported', `${_v2SelOrg}.json downloaded`, 'ok');
8380
+ } catch(e) { showToast('Export failed', e.message, 'err'); }
8381
+ };
8382
+ window.v2ImportOrgTrigger = function() {
8383
+ let inp = document.getElementById('org-import-file');
8384
+ if (!inp) {
8385
+ inp = document.createElement('input');
8386
+ inp.type = 'file'; inp.id = 'org-import-file'; inp.accept = '.json'; inp.style.display = 'none';
8387
+ document.body.appendChild(inp);
8388
+ }
8389
+ inp.onchange = v2HandleImportFile;
8390
+ inp.click();
8391
+ };
8392
+ async function v2HandleImportFile(e) {
8393
+ const file = e.target.files?.[0];
8394
+ e.target.value = '';
8395
+ if (!file) return;
8396
+ try {
8397
+ const text = await file.text();
8398
+ const data = JSON.parse(text);
8399
+ const name = data.name || file.name.replace(/\.json$/, '');
8400
+ const r = await fetch('/api/orgs', {
8401
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
8402
+ body: JSON.stringify({ ...data, name, dir: DIR || undefined }),
8403
+ });
8404
+ if (!r.ok) { const d = await r.json().catch(()=>({})); throw new Error(d.error || 'HTTP ' + r.status); }
8405
+ showToast('Imported', `Org "${name}" imported`, 'ok');
8406
+ await renderOrgs();
8407
+ if (name) v2SelectOrg(name);
8408
+ } catch(e) { showToast('Import failed', e.message, 'err'); }
8409
+ }
7174
8410
 
7175
8411
  function filterLoopList(q) {
7176
8412
  const query = q.trim().toLowerCase();
@@ -7192,13 +8428,23 @@ function filterOrgList(q) {
7192
8428
  // live SSE for org events
7193
8429
  (function v2OrgSSE() {
7194
8430
  let src;
8431
+ let seenKeys = new Set();
8432
+ let reconnectTimer = null;
7195
8433
  function connect() {
8434
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
7196
8435
  if (src) src.close();
8436
+ // Do NOT reset seenKeys here — the server replays ~50 events on every reconnect and
8437
+ // clearing the set lets all replays through, duplicating _v2OrgEventLog and _activity entries.
7197
8438
  src = new EventSource('/api/mastermind-stream');
7198
8439
  src.onmessage = e => {
7199
8440
  try {
7200
8441
  const ev = JSON.parse(e.data);
7201
- if (!ev?.org || !ev?.type?.startsWith('org:')) return;
8442
+ if (!ev?.org) return;
8443
+ if (!ev?.type?.startsWith('org:') && ev?.type !== 'run:start' && ev?.type !== 'run:complete' && ev?.type !== 'run:cycle:complete') return;
8444
+ const _sk = (ev.ts||'') + '|' + (ev.type||'') + '|' + (ev.org||'') + '|' + (ev.runId||ev.session||'') + '|' + (ev.from||ev.role||ev.agent||'') + '|' + (ev.result||ev.msg||ev.message||ev.summary||'').slice(0,20);
8445
+ if (seenKeys.has(_sk)) return;
8446
+ seenKeys.add(_sk);
8447
+ if (seenKeys.size > 2000) { const _a = [...seenKeys]; seenKeys = new Set(_a.slice(1000)); }
7202
8448
  const n = ev.org;
7203
8449
  // Filter by project dir if the event carries one; skip events from other projects.
7204
8450
  // Events without a project field are treated as belonging to the current project.
@@ -7208,7 +8454,63 @@ function filterOrgList(q) {
7208
8454
  _v2OrgEventLog[n].push(ev);
7209
8455
  if (_v2OrgEventLog[n].length > 50) _v2OrgEventLog[n].shift();
7210
8456
  if (n === _v2SelOrg && _v2OrgTab === 'activity') v2RenderOrgActivity();
7211
- if (ev.type === 'org:start' || ev.type === 'org:stop' || ev.type === 'org:delete' || ev.type === 'org:create') {
8457
+ // Update in-memory agent status so Live tab running-agents section reflects reality.
8458
+ // org:agent:online → mark that agent running; org:complete/run:complete → reset all to idle.
8459
+ if (n === _v2SelOrg && _v2OrgData && Array.isArray(_v2OrgData._agents)) {
8460
+ if (ev.type === 'org:agent:online' && ev.role) {
8461
+ const _liveAgent = _v2OrgData._agents.find(a => a.id === ev.role);
8462
+ if (_liveAgent) { _liveAgent.status = 'running'; _liveAgent.title = _liveAgent.title || ev.title || ev.role; }
8463
+ else _v2OrgData._agents.push({ id: ev.role, title: ev.title || ev.role, adapterType: ev.agent_type || null, status: 'running' });
8464
+ } else if (ev.type === 'org:agent:offline' && ev.role) {
8465
+ const _offAgent = _v2OrgData._agents.find(a => a.id === ev.role);
8466
+ if (_offAgent) _offAgent.status = 'idle';
8467
+ } else if (ev.type === 'org:complete' || ev.type === 'run:complete') {
8468
+ _v2OrgData._agents.forEach(a => { a.status = 'idle'; });
8469
+ }
8470
+ if (_v2OrgTab === 'agents-full' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2RenderOrgAgentsFull();
8471
+ else if (_v2OrgTab === 'live' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2RenderOrgLive();
8472
+ else if (_v2OrgTab === 'heartbeats' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2RenderOrgHeartbeats();
8473
+ else if (_v2OrgTab === 'members' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2RenderOrgMembers();
8474
+ else if (_v2OrgTab === 'roles' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2RenderOrgRoles();
8475
+ else if (_v2OrgTab === 'chart' && (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete')) v2UpdateChartRunningDots();
8476
+ // Health tab: update KPI counters live from SSE events so agents_active and
8477
+ // tasks_pending reflect current state without waiting for a run-event re-fetch.
8478
+ if (_v2OrgTab === 'health' && _v2OrgData._health) {
8479
+ if (ev.type === 'org:agent:online' || ev.type === 'org:agent:offline' || ev.type === 'org:complete' || ev.type === 'run:complete') {
8480
+ _v2OrgData._health.agents_active = _v2OrgData._agents.filter(a => a.status === 'running').length;
8481
+ v2RenderOrgHealth();
8482
+ }
8483
+ if (ev.type === 'org:checkpoint' && ev.pending_tasks != null) {
8484
+ _v2OrgData._health.tasks_pending = ev.pending_tasks;
8485
+ v2RenderOrgHealth();
8486
+ }
8487
+ if (ev.type === 'run:cycle:complete' && ev.pending != null) {
8488
+ _v2OrgData._health.tasks_pending = ev.pending;
8489
+ v2RenderOrgHealth();
8490
+ }
8491
+ }
8492
+ }
8493
+ // Also push live org:comms / org:checkpoint events directly into the Live tab
8494
+ // activity feed so they appear in real-time without waiting for the 5s poll.
8495
+ if (n === _v2SelOrg && _v2OrgTab === 'live' && _v2OrgData) {
8496
+ if (!Array.isArray(_v2OrgData._activity)) _v2OrgData._activity = [];
8497
+ _v2OrgData._activity.push({ ...ev, _sseOnly: true });
8498
+ v2RenderOrgLive();
8499
+ }
8500
+ // Pulse the org list dot on comms/checkpoint events so the user can see the org is active
8501
+ // without switching to the activity tab
8502
+ if (ev.type === 'org:comms' || ev.type === 'org:checkpoint') {
8503
+ const _orgDot = document.querySelector(`.org-item[data-org="${CSS.escape(n)}"] .oi-dot`);
8504
+ if (_orgDot) {
8505
+ _orgDot.classList.add('running');
8506
+ // Brief flash to indicate activity — class stays if org is running
8507
+ setTimeout(() => {
8508
+ const _listOrg = _v2Orgs.find(o => o.name === n) || {};
8509
+ if (!_listOrg.running) _orgDot.classList.remove('running');
8510
+ }, 2000);
8511
+ }
8512
+ }
8513
+ if (ev.type === 'org:start' || ev.type === 'org:stop' || ev.type === 'org:delete' || ev.type === 'org:create' || ev.type === 'run:start' || ev.type === 'run:complete') {
7212
8514
  setTimeout(async () => {
7213
8515
  await renderOrgs();
7214
8516
  if (_v2SelOrg === n) {
@@ -7223,14 +8525,102 @@ function filterOrgList(q) {
7223
8525
  const listOrg = _v2Orgs.find(o => o.name === _v2SelOrg) || {};
7224
8526
  if (_v2OrgData) _v2OrgData.running = listOrg.running;
7225
8527
  v2UpdateOrgHeader(listOrg, _v2OrgData);
7226
- if (_v2OrgTab === 'health') v2RenderOrgHealth();
8528
+ if (_v2OrgTab === 'health') {
8529
+ // Re-fetch health after run events so stats (agents_active, success_rate, total_runs) are current
8530
+ const _hEnc = encodeURIComponent(_v2SelOrg);
8531
+ fetch(`/api/org/${_hEnc}/health${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8532
+ .then(r => r.ok ? r.json() : null)
8533
+ .then(h => { if (h && _v2OrgData && _v2SelOrg === n) { _v2OrgData._health = h; v2RenderOrgHealth(); } })
8534
+ .catch(() => { v2RenderOrgHealth(); });
8535
+ }
8536
+ if (_v2OrgTab === 'tasks') {
8537
+ // Re-fetch org data after run events so tasks (todo/doing/done) reflect current store state
8538
+ const _tEnc = encodeURIComponent(_v2SelOrg);
8539
+ fetch(`/api/org/${_tEnc}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8540
+ .then(r => r.ok ? r.json() : null)
8541
+ .then(d => { if (d && _v2OrgData && _v2SelOrg === n && d.tasks !== undefined) { _v2OrgData.tasks = d.tasks; v2RenderOrgTasks(); } })
8542
+ .catch(() => { v2RenderOrgTasks(); });
8543
+ }
8544
+ if (_v2OrgTab === 'budgets' || _v2OrgTab === 'costs') {
8545
+ // Re-fetch budgets after run events so token/cost counters stay current
8546
+ const _bEnc = encodeURIComponent(_v2SelOrg);
8547
+ fetch(`/api/org/${_bEnc}/budgets${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8548
+ .then(r => r.ok ? r.json() : null)
8549
+ .then(b => { if (b && _v2OrgData && _v2SelOrg === n) { _v2OrgData._budgets = b; _v2OrgTab === 'budgets' ? v2RenderOrgBudgets() : v2RenderOrgCosts(); } })
8550
+ .catch(() => { _v2OrgTab === 'budgets' ? v2RenderOrgBudgets() : v2RenderOrgCosts(); });
8551
+ }
8552
+ if (_v2OrgTab === 'board' || _v2OrgTab === 'issues-full') {
8553
+ // Re-fetch issues after run events so board/issues-full reflect current state
8554
+ const _iEnc = encodeURIComponent(_v2SelOrg);
8555
+ fetch(`/api/org/${_iEnc}/issues${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8556
+ .then(r => r.ok ? r.json() : null)
8557
+ .then(d => { if (d && _v2OrgData && _v2SelOrg === n) { _v2OrgData._issues = Array.isArray(d) ? d : (d?.issues || []); _v2OrgTab === 'board' ? v2RenderOrgBoard() : v2RenderOrgIssuesFull(); } })
8558
+ .catch(() => { _v2OrgTab === 'board' ? v2RenderOrgBoard() : v2RenderOrgIssuesFull(); });
8559
+ }
8560
+ if (_v2OrgTab === 'goals') {
8561
+ // Re-fetch org data after run events so goal progress (filled/total/status) reflects current state
8562
+ const _glEnc = encodeURIComponent(_v2SelOrg);
8563
+ fetch(`/api/org/${_glEnc}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8564
+ .then(r => r.ok ? r.json() : null)
8565
+ .then(d => { if (d && _v2OrgData && _v2SelOrg === n && d.goals !== undefined) { _v2OrgData.goals = d.goals; v2RenderOrgGoals(); } })
8566
+ .catch(() => { v2RenderOrgGoals(); });
8567
+ }
8568
+ if (_v2OrgTab === 'chat') {
8569
+ // Re-load chat run list after run events so cycleCount/eventCount in dropdown reflect disk state
8570
+ _odtLoadChatSessions().catch(() => {});
8571
+ }
8572
+ if (_v2OrgTab === 'agents-full' || _v2OrgTab === 'heartbeats') {
8573
+ // Re-fetch agents after run events to get fresh tokensIn/tokensOut/lastHeartbeat
8574
+ // Heartbeats tab derives from _v2OrgData._agents so shares this re-fetch.
8575
+ const _agEnc = encodeURIComponent(_v2SelOrg);
8576
+ fetch(`/api/org/${_agEnc}/agents${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8577
+ .then(r => r.ok ? r.json() : null)
8578
+ .then(d => { if (d && _v2OrgData && _v2SelOrg === n) { _v2OrgData._agents = Array.isArray(d) ? d : (d?.agents || []); _v2OrgTab === 'agents-full' ? v2RenderOrgAgentsFull() : v2RenderOrgHeartbeats(); } })
8579
+ .catch(() => { _v2OrgTab === 'agents-full' ? v2RenderOrgAgentsFull() : v2RenderOrgHeartbeats(); });
8580
+ }
8581
+ if (_v2OrgTab === 'myissues') {
8582
+ // Re-render myissues after run events — it fetches its own data on every render
8583
+ v2RenderOrgMyIssues();
8584
+ }
8585
+ if (_v2OrgTab === 'charts') {
8586
+ // Re-fetch activity after run events so the 14-day heatmap reflects the completed run
8587
+ const _chEnc = encodeURIComponent(_v2SelOrg);
8588
+ fetch(`/api/org/${_chEnc}/activity${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8589
+ .then(r => r.ok ? r.json() : [])
8590
+ .then(d => { if (_v2OrgData && _v2SelOrg === n) { _v2OrgData._activity = Array.isArray(d) ? d : []; v2RenderOrgCharts(); } })
8591
+ .catch(() => { v2RenderOrgCharts(); });
8592
+ }
7227
8593
  }
7228
8594
  }
7229
8595
  }, 400);
7230
8596
  }
8597
+ // run:cycle:complete: lightweight re-fetch for budgets/costs/tasks without renderOrgs()
8598
+ // — cycles fire frequently and only affect token counters and task state, not org-list status.
8599
+ if (ev.type === 'run:cycle:complete' && _v2SelOrg === n && _v2OrgData) {
8600
+ if (_v2OrgTab === 'budgets' || _v2OrgTab === 'costs') {
8601
+ const _bcEnc = encodeURIComponent(_v2SelOrg);
8602
+ fetch(`/api/org/${_bcEnc}/budgets${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8603
+ .then(r => r.ok ? r.json() : null)
8604
+ .then(b => { if (b && _v2OrgData && _v2SelOrg === n) { _v2OrgData._budgets = b; _v2OrgTab === 'budgets' ? v2RenderOrgBudgets() : v2RenderOrgCosts(); } })
8605
+ .catch(() => { _v2OrgTab === 'budgets' ? v2RenderOrgBudgets() : v2RenderOrgCosts(); });
8606
+ }
8607
+ if (_v2OrgTab === 'tasks') {
8608
+ const _tcEnc = encodeURIComponent(_v2SelOrg);
8609
+ fetch(`/api/org/${_tcEnc}${DIR ? '?dir=' + encodeURIComponent(DIR) : ''}`)
8610
+ .then(r => r.ok ? r.json() : null)
8611
+ .then(d => { if (d && _v2OrgData && _v2SelOrg === n && d.tasks !== undefined) { _v2OrgData.tasks = d.tasks; v2RenderOrgTasks(); } })
8612
+ .catch(() => { v2RenderOrgTasks(); });
8613
+ }
8614
+ }
7231
8615
  } catch(_) {}
7232
8616
  };
7233
- src.onerror = () => setTimeout(connect, 5000);
8617
+ src.onerror = () => {
8618
+ // Close immediately to stop browser's built-in auto-retry (zombie window).
8619
+ // Guard reconnectTimer so multiple onerror firings don't stack timers that
8620
+ // would then cancel each other's freshly-created connections.
8621
+ const _s = src; src = null; if (_s) _s.close();
8622
+ if (!reconnectTimer) reconnectTimer = setTimeout(connect, 5000);
8623
+ };
7234
8624
  }
7235
8625
  connect();
7236
8626
  })();
@@ -10247,8 +11637,8 @@ function selectMem(filename) {
10247
11637
  const actions = (f.readonly || f.source === 'backend')
10248
11638
  ? '<div class="mem-actions"><span style="font-size:11px;color:var(--text-lo)">Read-only — stored in the backend memory store (not a file)</span></div>'
10249
11639
  : '<div class="mem-actions">' +
10250
- '<button class="btn" onclick="openEditMemModal(' + JSON.stringify(filename) + ')">&#x270E; Edit</button>' +
10251
- '<button class="btn" style="color:var(--red);border-color:var(--red)" onclick="deleteMem(' + JSON.stringify(filename) + ')">&#x2715; Delete</button>' +
11640
+ '<button class="btn" data-fn="' + esc(filename) + '" onclick="openEditMemModal(this.dataset.fn)">&#x270E; Edit</button>' +
11641
+ '<button class="btn" style="color:var(--red);border-color:var(--red)" data-fn="' + esc(filename) + '" onclick="deleteMem(this.dataset.fn)">&#x2715; Delete</button>' +
10252
11642
  '</div>';
10253
11643
  detail.innerHTML =
10254
11644
  '<span class="mem-badge" style="background:' + col + '22;color:' + col + '">' + esc(f.type || '?') + '</span>' + srcBadge +
@@ -10802,7 +12192,7 @@ function mmRenderOrgs(body) {
10802
12192
  if (!orgs.length) { body.innerHTML = '<div class="empty">No orgs found. Run /mastermind:createorg to create one.</div>'; return; }
10803
12193
  body.innerHTML = orgs.map(o => {
10804
12194
  const running = o.running;
10805
- return `<div class="mm-skill-item" onclick="closeMastermind();v2SelectOrg(${JSON.stringify(o.name)});switchView('orgs')">
12195
+ return `<div class="mm-skill-item" data-org="${esc(o.name)}" onclick="closeMastermind();v2SelectOrg(this.dataset.org);switchView('orgs')">
10806
12196
  <span class="mm-skill-name">${esc(o.name)}</span>
10807
12197
  <span class="mm-skill-desc">${esc((o.goal || '').slice(0, 60))} ${running ? '⬤ LIVE' : ''}</span>
10808
12198
  </div>`;
@@ -10814,7 +12204,7 @@ function mmRenderSkills(body) {
10814
12204
  const q = _mmSkillFilter.toLowerCase();
10815
12205
  const filtered = q ? _MM_SKILLS_CATALOG.filter(s => s.name.toLowerCase().includes(q) || s.desc.toLowerCase().includes(q)) : _MM_SKILLS_CATALOG;
10816
12206
  body.innerHTML = `<div class="filter-bar" style="margin-bottom:12px"><input class="filter-input" type="text" placeholder="Search skills…" value="${esc(_mmSkillFilter)}" oninput="_mmSkillFilter=this.value;mmRenderSkills(document.getElementById('mm-body'))"></div>` +
10817
- filtered.map(s => `<div class="mm-skill-item" onclick="navigator.clipboard.writeText(${JSON.stringify(s.name)}).then(()=>showToast('Copied',${JSON.stringify(s.name)},'ok'))">
12207
+ filtered.map(s => `<div class="mm-skill-item" data-sn="${esc(s.name)}" onclick="navigator.clipboard.writeText(this.dataset.sn).then(()=>showToast('Copied',this.dataset.sn,'ok'))">
10818
12208
  <span class="mm-skill-name">${esc(s.name)}</span>
10819
12209
  <span class="mm-skill-desc">${esc(s.desc)}</span>
10820
12210
  </div>`).join('');
@@ -10829,7 +12219,7 @@ async function mmRenderLoops(body) {
10829
12219
  body.innerHTML = loops.map(l => {
10830
12220
  const maxReps = l.maxReps || 0;
10831
12221
  const curRep = l.currentRep || 0;
10832
- const isTillend = !maxReps || l.loopType === 'tillend' || String(l.command || '').includes('--tillend');
12222
+ const isTillend = !maxReps || l.type === 'tillend' || String(l.command || '').includes('--tillend');
10833
12223
  const nextAt = l.nextRunAt ? parseInt(l.nextRunAt, 10) : 0;
10834
12224
  const isExplicitlyActive = l.status === 'running' || l.status === 'waiting' || l.status === 'active';
10835
12225
  const isOverdue = !isExplicitlyActive && nextAt > 0 && nextAt <= Date.now();
@@ -10847,7 +12237,7 @@ async function mmRenderLoops(body) {
10847
12237
  <span style="font-size:13px;color:var(--text-hi);flex:1">${esc(name)}</span>
10848
12238
  ${isHil ? '<span style="color:oklch(78% 0.18 80);font-size:10px">⚠ HIL</span>' : ''}
10849
12239
  <span class="ss-pill ${running ? 'on' : ''}">${running ? 'active' : isFinished ? 'done' : 'stopped'}</span>
10850
- ${(running || isHil) ? `<button class="btn" style="font-size:10px;color:var(--red);border-color:var(--red)" onclick="stopLoop(event,${JSON.stringify(l.id||l.name||'')});mmSwitchTab('loops')">■ Stop</button>` : ''}
12240
+ ${(running || isHil) ? `<button class="btn" style="font-size:10px;color:var(--red);border-color:var(--red)" data-lid="${esc(l.id||l.name||'')}" onclick="stopLoop(event,this.dataset.lid);mmSwitchTab('loops')">■ Stop</button>` : ''}
10851
12241
  </div>
10852
12242
  <div style="font-size:11px;color:var(--text-lo);margin-top:4px;font-family:var(--mono)">${esc(fmtInterval(l.interval||l.schedule||''))} ${cdown ? '· ' + cdown : ''} ${curRep != null ? '· run ' + curRep + (isTillend ? '/∞' : maxReps ? '/'+maxReps : '') : ''}</div>
10853
12243
  </div>`;