@ashlr/hub 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +59 -2
  3. package/dist/api/plugin.d.ts +1 -0
  4. package/dist/cli/backlog.js +2 -0
  5. package/dist/cli/backlog.js.map +1 -1
  6. package/dist/cli/digest.d.ts +6 -0
  7. package/dist/cli/digest.js +127 -2
  8. package/dist/cli/digest.js.map +1 -1
  9. package/dist/cli/eval-fixtures.d.ts +18 -0
  10. package/dist/cli/eval-fixtures.js +38 -0
  11. package/dist/cli/eval-fixtures.js.map +1 -0
  12. package/dist/cli/eval.d.ts +56 -0
  13. package/dist/cli/eval.js +283 -0
  14. package/dist/cli/eval.js.map +1 -0
  15. package/dist/cli/fleet.d.ts +27 -0
  16. package/dist/cli/fleet.js +222 -0
  17. package/dist/cli/fleet.js.map +1 -0
  18. package/dist/cli/goal.d.ts +15 -0
  19. package/dist/cli/goal.js +91 -0
  20. package/dist/cli/goal.js.map +1 -0
  21. package/dist/cli/help.js +10 -3
  22. package/dist/cli/help.js.map +1 -1
  23. package/dist/cli/inbox.d.ts +6 -0
  24. package/dist/cli/inbox.js +183 -8
  25. package/dist/cli/inbox.js.map +1 -1
  26. package/dist/cli/index.js +42 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/loop.d.ts +16 -0
  29. package/dist/cli/loop.js +66 -0
  30. package/dist/cli/loop.js.map +1 -0
  31. package/dist/cli/mcp.d.ts +12 -0
  32. package/dist/cli/mcp.js +148 -0
  33. package/dist/cli/mcp.js.map +1 -1
  34. package/dist/cli/onboard.d.ts +16 -0
  35. package/dist/cli/onboard.js +71 -0
  36. package/dist/cli/onboard.js.map +1 -1
  37. package/dist/cli/plugins.js +9 -8
  38. package/dist/cli/plugins.js.map +1 -1
  39. package/dist/cli/pulse.d.ts +10 -0
  40. package/dist/cli/pulse.js +261 -0
  41. package/dist/cli/pulse.js.map +1 -1
  42. package/dist/cli/run.js +21 -3
  43. package/dist/cli/run.js.map +1 -1
  44. package/dist/cli/stack.d.ts +34 -0
  45. package/dist/cli/stack.js +221 -0
  46. package/dist/cli/stack.js.map +1 -0
  47. package/dist/cli/update.js +12 -5
  48. package/dist/cli/update.js.map +1 -1
  49. package/dist/core/daemon/loop.d.ts +10 -5
  50. package/dist/core/daemon/loop.js +167 -37
  51. package/dist/core/daemon/loop.js.map +1 -1
  52. package/dist/core/env-bridge.js +3 -2
  53. package/dist/core/env-bridge.js.map +1 -1
  54. package/dist/core/fleet/automerge-pass.d.ts +33 -0
  55. package/dist/core/fleet/automerge-pass.js +61 -0
  56. package/dist/core/fleet/automerge-pass.js.map +1 -0
  57. package/dist/core/fleet/quota.d.ts +70 -0
  58. package/dist/core/fleet/quota.js +198 -0
  59. package/dist/core/fleet/quota.js.map +1 -0
  60. package/dist/core/fleet/router.d.ts +46 -0
  61. package/dist/core/fleet/router.js +121 -0
  62. package/dist/core/fleet/router.js.map +1 -0
  63. package/dist/core/fleet/self.d.ts +58 -0
  64. package/dist/core/fleet/self.js +187 -0
  65. package/dist/core/fleet/self.js.map +1 -0
  66. package/dist/core/fleet/status.d.ts +54 -0
  67. package/dist/core/fleet/status.js +157 -0
  68. package/dist/core/fleet/status.js.map +1 -0
  69. package/dist/core/foundry/provenance.d.ts +78 -0
  70. package/dist/core/foundry/provenance.js +172 -0
  71. package/dist/core/foundry/provenance.js.map +1 -0
  72. package/dist/core/git.d.ts +13 -0
  73. package/dist/core/git.js +26 -0
  74. package/dist/core/git.js.map +1 -1
  75. package/dist/core/inbox/merge.d.ts +151 -0
  76. package/dist/core/inbox/merge.js +858 -0
  77. package/dist/core/inbox/merge.js.map +1 -0
  78. package/dist/core/inbox/store.js +10 -1
  79. package/dist/core/inbox/store.js.map +1 -1
  80. package/dist/core/integrations/markdown.d.ts +66 -0
  81. package/dist/core/integrations/markdown.js +132 -0
  82. package/dist/core/integrations/markdown.js.map +1 -0
  83. package/dist/core/integrations/secrets.d.ts +37 -0
  84. package/dist/core/integrations/secrets.js +67 -0
  85. package/dist/core/integrations/secrets.js.map +1 -0
  86. package/dist/core/integrations/stack.d.ts +47 -0
  87. package/dist/core/integrations/stack.js +107 -0
  88. package/dist/core/integrations/stack.js.map +1 -0
  89. package/dist/core/mcp-native-engineer.d.ts +123 -0
  90. package/dist/core/mcp-native-engineer.js +936 -0
  91. package/dist/core/mcp-native-engineer.js.map +1 -0
  92. package/dist/core/mcp-native.js +20 -42
  93. package/dist/core/mcp-native.js.map +1 -1
  94. package/dist/core/mcp-registry.js +2 -0
  95. package/dist/core/mcp-registry.js.map +1 -1
  96. package/dist/core/observability/codex-source.d.ts +53 -0
  97. package/dist/core/observability/codex-source.js +390 -0
  98. package/dist/core/observability/codex-source.js.map +1 -0
  99. package/dist/core/observability/estimate.d.ts +12 -0
  100. package/dist/core/observability/estimate.js +26 -0
  101. package/dist/core/observability/estimate.js.map +1 -1
  102. package/dist/core/observability/limits.d.ts +52 -0
  103. package/dist/core/observability/limits.js +316 -0
  104. package/dist/core/observability/limits.js.map +1 -0
  105. package/dist/core/observability/usage-source.js +3 -1
  106. package/dist/core/observability/usage-source.js.map +1 -1
  107. package/dist/core/portfolio/scanners.d.ts +1 -0
  108. package/dist/core/portfolio/scanners.js +40 -1
  109. package/dist/core/portfolio/scanners.js.map +1 -1
  110. package/dist/core/providers.js +4 -0
  111. package/dist/core/providers.js.map +1 -1
  112. package/dist/core/quality/health.js +1 -0
  113. package/dist/core/quality/health.js.map +1 -1
  114. package/dist/core/run/agent-loop.d.ts +8 -0
  115. package/dist/core/run/agent-loop.js +24 -14
  116. package/dist/core/run/agent-loop.js.map +1 -1
  117. package/dist/core/run/engine-registry.d.ts +48 -0
  118. package/dist/core/run/engine-registry.js +190 -0
  119. package/dist/core/run/engine-registry.js.map +1 -0
  120. package/dist/core/run/engines.d.ts +17 -3
  121. package/dist/core/run/engines.js +63 -44
  122. package/dist/core/run/engines.js.map +1 -1
  123. package/dist/core/run/learned-router.d.ts +121 -0
  124. package/dist/core/run/learned-router.js +374 -0
  125. package/dist/core/run/learned-router.js.map +1 -0
  126. package/dist/core/run/model-manager.js +3 -3
  127. package/dist/core/run/model-manager.js.map +1 -1
  128. package/dist/core/run/model-profile.d.ts +67 -0
  129. package/dist/core/run/model-profile.js +132 -0
  130. package/dist/core/run/model-profile.js.map +1 -0
  131. package/dist/core/run/orchestrator.d.ts +1 -1
  132. package/dist/core/run/orchestrator.js +642 -510
  133. package/dist/core/run/orchestrator.js.map +1 -1
  134. package/dist/core/run/prompts/budget.d.ts +25 -0
  135. package/dist/core/run/prompts/budget.js +51 -0
  136. package/dist/core/run/prompts/budget.js.map +1 -0
  137. package/dist/core/run/prompts/index.d.ts +14 -0
  138. package/dist/core/run/prompts/index.js +59 -0
  139. package/dist/core/run/prompts/index.js.map +1 -0
  140. package/dist/core/run/prompts/layers.d.ts +26 -0
  141. package/dist/core/run/prompts/layers.js +73 -0
  142. package/dist/core/run/prompts/layers.js.map +1 -0
  143. package/dist/core/run/prompts/roles.d.ts +23 -0
  144. package/dist/core/run/prompts/roles.js +48 -0
  145. package/dist/core/run/prompts/roles.js.map +1 -0
  146. package/dist/core/run/prompts/types.d.ts +38 -0
  147. package/dist/core/run/prompts/types.js +5 -0
  148. package/dist/core/run/prompts/types.js.map +1 -0
  149. package/dist/core/run/provider-client.d.ts +6 -24
  150. package/dist/core/run/provider-client.js +125 -61
  151. package/dist/core/run/provider-client.js.map +1 -1
  152. package/dist/core/run/router.js +8 -0
  153. package/dist/core/run/router.js.map +1 -1
  154. package/dist/core/run/sandboxed-engine.d.ts +66 -0
  155. package/dist/core/run/sandboxed-engine.js +262 -0
  156. package/dist/core/run/sandboxed-engine.js.map +1 -0
  157. package/dist/core/run/verify-commands.d.ts +83 -0
  158. package/dist/core/run/verify-commands.js +234 -0
  159. package/dist/core/run/verify-commands.js.map +1 -0
  160. package/dist/core/run/verify.d.ts +31 -1
  161. package/dist/core/run/verify.js +48 -0
  162. package/dist/core/run/verify.js.map +1 -1
  163. package/dist/core/sandbox/audit.d.ts +27 -0
  164. package/dist/core/sandbox/audit.js +34 -0
  165. package/dist/core/sandbox/audit.js.map +1 -1
  166. package/dist/core/sandbox/confine.d.ts +134 -0
  167. package/dist/core/sandbox/confine.js +370 -0
  168. package/dist/core/sandbox/confine.js.map +1 -0
  169. package/dist/core/types.d.ts +227 -5
  170. package/dist/core/web/api.js +77 -28
  171. package/dist/core/web/api.js.map +1 -1
  172. package/dist/core/web/control.d.ts +116 -0
  173. package/dist/core/web/control.js +307 -0
  174. package/dist/core/web/control.js.map +1 -0
  175. package/dist/core/web/public/app.js +512 -6
  176. package/dist/core/web/public/styles.css +468 -0
  177. package/package.json +1 -1
@@ -24,8 +24,8 @@
24
24
  // Constants
25
25
  // ---------------------------------------------------------------------------
26
26
 
27
- const VIEWS = ['overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon'];
28
- const DEFAULT_VIEW = 'overview';
27
+ const VIEWS = ['control', 'overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon', 'fleet'];
28
+ const DEFAULT_VIEW = 'control';
29
29
  const API_BASE = ''; // same origin
30
30
 
31
31
  // sessionStorage key for the session token (never localStorage, never URL).
@@ -64,6 +64,9 @@ const state = {
64
64
  inbox: { pending: 0, proposals: [] },
65
65
  inboxDetail: null, // currently-open Proposal (full, with diff)
66
66
  daemon: null, // DaemonState | null
67
+ fleet: null, // M49: FleetStatus | null
68
+ control: null, // M61: GET /api/control composite view
69
+ controlInterval: null, // M61: 4s polling timer
67
70
  inboxBadge: 0, // pending count from SSE, drives nav badge
68
71
  loading: {}, // viewName -> boolean
69
72
  error: {}, // viewName -> string | null
@@ -269,6 +272,8 @@ function connectSSE() {
269
272
  try {
270
273
  state.daemon = JSON.parse(e.data);
271
274
  if (state.activeView === 'daemon') renderDaemon();
275
+ // M61: control view picks up daemon events
276
+ if (state.activeView === 'control') loadControl();
272
277
  } catch {}
273
278
  });
274
279
  es.addEventListener('error', () => {
@@ -322,7 +327,8 @@ async function onHashChange() {
322
327
 
323
328
  function renderActiveView() {
324
329
  const view = state.activeView;
325
- if (view === 'overview') renderOverview();
330
+ if (view === 'control') renderControl();
331
+ else if (view === 'overview') renderOverview();
326
332
  else if (view === 'runs') renderRuns();
327
333
  else if (view === 'swarms') renderSwarms();
328
334
  else if (view === 'pulse') renderPulse();
@@ -330,10 +336,17 @@ function renderActiveView() {
330
336
  else if (view === 'portfolio') renderPortfolio();
331
337
  else if (view === 'inbox') renderInbox();
332
338
  else if (view === 'daemon') renderDaemon();
339
+ else if (view === 'fleet') renderFleet();
333
340
  }
334
341
 
335
342
  async function loadView(view) {
336
- if (view === 'overview') await loadOverview();
343
+ // Stop control polling when navigating away
344
+ if (view !== 'control' && state.controlInterval) {
345
+ clearInterval(state.controlInterval);
346
+ state.controlInterval = null;
347
+ }
348
+ if (view === 'control') await loadControl();
349
+ else if (view === 'overview') await loadOverview();
337
350
  else if (view === 'runs') await loadRuns();
338
351
  else if (view === 'swarms') await loadSwarms();
339
352
  else if (view === 'pulse') await loadPulse();
@@ -341,6 +354,7 @@ async function loadView(view) {
341
354
  else if (view === 'portfolio') await loadPortfolio();
342
355
  else if (view === 'inbox') await loadInbox();
343
356
  else if (view === 'daemon') await loadDaemon();
357
+ else if (view === 'fleet') await loadFleet();
344
358
  }
345
359
 
346
360
  // ---------------------------------------------------------------------------
@@ -362,6 +376,7 @@ function renderShell() {
362
376
 
363
377
  // Nav icons per view (inline SVG path data)
364
378
  const VIEW_ICONS = {
379
+ control: '<rect x="1" y="1" width="14" height="9" rx="1.5" stroke="currentColor" stroke-width="1.4" fill="none"/><line x1="4" y1="13" x2="12" y2="13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><line x1="8" y1="10" x2="8" y2="13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="5" cy="5.5" r="1.3" fill="currentColor" opacity=".9"/><circle cx="8" cy="5.5" r="1.3" fill="currentColor" opacity=".6"/><circle cx="11" cy="5.5" r="1.3" fill="currentColor" opacity=".35"/>',
365
380
  overview: '<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".85"/><rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".3"/>',
366
381
  runs: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><polyline points="5.5,8 7.5,10 10.5,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
367
382
  swarms: '<circle cx="8" cy="3" r="2" fill="currentColor" opacity=".9"/><circle cx="3" cy="13" r="2" fill="currentColor" opacity=".7"/><circle cx="13" cy="13" r="2" fill="currentColor" opacity=".7"/><line x1="8" y1="5" x2="3" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="8" y1="5" x2="13" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="3" y1="13" x2="13" y2="13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
@@ -382,8 +397,10 @@ function renderShell() {
382
397
  iconSvg.setAttribute('aria-hidden', 'true');
383
398
  iconSvg.innerHTML = VIEW_ICONS[v] ?? '';
384
399
 
385
- const label = document.createTextNode(v.charAt(0).toUpperCase() + v.slice(1));
386
- const a = el('a', { cls: 'nav-link', href: `#${v}`, 'data-view': v });
400
+ const VIEW_LABELS = { control: 'Mission Control' };
401
+ const labelText = VIEW_LABELS[v] ?? (v.charAt(0).toUpperCase() + v.slice(1));
402
+ const label = document.createTextNode(labelText);
403
+ const a = el('a', { cls: `nav-link${v === 'control' ? ' nav-link--control' : ''}`, href: `#${v}`, 'data-view': v });
387
404
  a.appendChild(iconSvg);
388
405
  a.appendChild(label);
389
406
 
@@ -1843,6 +1860,108 @@ function renderDaemon() {
1843
1860
  main.appendChild(section);
1844
1861
  }
1845
1862
 
1863
+ // ---------------------------------------------------------------------------
1864
+ // M49: Fleet view — read-only fleet control plane snapshot
1865
+ // ---------------------------------------------------------------------------
1866
+
1867
+ async function loadFleet() {
1868
+ showLoading('fleet');
1869
+ try {
1870
+ state.fleet = await apiFetch('/api/fleet');
1871
+ renderFleet();
1872
+ } catch (err) {
1873
+ showError('fleet', err.message);
1874
+ }
1875
+ }
1876
+
1877
+ function quotaTag(quota) {
1878
+ const map = {
1879
+ ok: 'var(--status-done)',
1880
+ warn: 'var(--status-aborted)',
1881
+ over: 'var(--status-failed)',
1882
+ unlimited: 'var(--text-dim, #888)',
1883
+ };
1884
+ return el('span', {
1885
+ cls: 'fleet-quota',
1886
+ style: `color:${map[quota] || 'inherit'}`,
1887
+ title: `quota: ${quota}`,
1888
+ }, quota);
1889
+ }
1890
+
1891
+ function renderFleet() {
1892
+ if (state.activeView !== 'fleet') return;
1893
+ const main = getMain();
1894
+ if (!main) return;
1895
+ main.innerHTML = '';
1896
+
1897
+ const section = el('section', { cls: 'view-section' });
1898
+ section.appendChild(el('div', { cls: 'view-header' },
1899
+ el('h1', { cls: 'view-title' }, 'Fleet'),
1900
+ el('span', { cls: 'view-subtitle' }, 'Control plane & observability')
1901
+ ));
1902
+
1903
+ const f = state.fleet;
1904
+ if (!f) {
1905
+ section.appendChild(el('div', { cls: 'empty-state' },
1906
+ el('p', {}, 'Fleet status unavailable.'),
1907
+ el('p', { cls: 'hint' }, 'Try again, or check the daemon with `ashlr fleet status`.')
1908
+ ));
1909
+ main.appendChild(section);
1910
+ return;
1911
+ }
1912
+
1913
+ // Paused / killed banner
1914
+ if (f.killed) {
1915
+ section.appendChild(el('div', { cls: 'fleet-banner fleet-banner--paused' },
1916
+ el('strong', {}, 'Fleet paused'),
1917
+ el('span', {}, ' — the kill switch is engaged. Resume with `ashlr fleet resume`.')
1918
+ ));
1919
+ }
1920
+
1921
+ // Daemon + queue + merges summary card
1922
+ const summary = el('div', { cls: 'fleet-card card' });
1923
+ summary.appendChild(infoGrid([
1924
+ ['Daemon', f.daemon.running ? 'running' : 'stopped'],
1925
+ ['Last tick', f.daemon.lastTickAt ? fmtRelative(f.daemon.lastTickAt) : '—'],
1926
+ ['Spend today', f.daemon.todaySpentUsd != null ? `$${f.daemon.todaySpentUsd.toFixed(4)}` : '—'],
1927
+ ['Backlog queue', f.queue?.backlogItems ?? '—'],
1928
+ ['Merges (24h)', f.merges?.recent ?? '—'],
1929
+ ]));
1930
+ section.appendChild(summary);
1931
+
1932
+ // Backends table
1933
+ const backendsCard = el('div', { cls: 'fleet-card card' });
1934
+ backendsCard.appendChild(el('h2', { cls: 'card-title' }, 'Backends'));
1935
+ const backends = Array.isArray(f.backends) ? f.backends : [];
1936
+ if (backends.length === 0) {
1937
+ backendsCard.appendChild(el('p', { cls: 'hint' }, 'No backends configured.'));
1938
+ } else {
1939
+ const list = el('div', { cls: 'fleet-backends' });
1940
+ for (const b of backends) {
1941
+ const row = el('div', { cls: 'fleet-backend-row' },
1942
+ el('span', { cls: 'fleet-backend-name' }, b.backend),
1943
+ el('span', { cls: 'fleet-backend-dispatches' }, `${b.dispatchesRecent} dispatch(es) / 24h`),
1944
+ quotaTag(b.quota)
1945
+ );
1946
+ list.appendChild(row);
1947
+ }
1948
+ backendsCard.appendChild(list);
1949
+ }
1950
+ section.appendChild(backendsCard);
1951
+
1952
+ // Proposals card
1953
+ const propsCard = el('div', { cls: 'fleet-card card' });
1954
+ propsCard.appendChild(el('h2', { cls: 'card-title' }, 'Proposals'));
1955
+ propsCard.appendChild(infoGrid([
1956
+ ['Pending', f.proposals?.pending ?? 0],
1957
+ ['Frontier pending', f.proposals?.frontierPending ?? 0],
1958
+ ['Applied', f.proposals?.applied ?? 0],
1959
+ ]));
1960
+ section.appendChild(propsCard);
1961
+
1962
+ main.appendChild(section);
1963
+ }
1964
+
1846
1965
  /** Format a signed delta for the portfolio "today" block. null → em-dash. */
1847
1966
  function fmtDelta(n, prefix = '') {
1848
1967
  if (n === null || n === undefined || typeof n !== 'number' || !isFinite(n)) return '—';
@@ -1879,6 +1998,393 @@ function updateSseDot(connected) {
1879
1998
  dot.classList.toggle('connected', connected);
1880
1999
  }
1881
2000
 
2001
+ // ---------------------------------------------------------------------------
2002
+ // Mission Control (M61) — /api/control composite live view
2003
+ // ---------------------------------------------------------------------------
2004
+
2005
+ async function loadControl() {
2006
+ // Guard against overlapping fetches: /api/control aggregates a usage rollup
2007
+ // that can take a beat, and on single-threaded Node a stacked poll would pile
2008
+ // up requests (and freeze the tab waiting). Skip if one is already in flight.
2009
+ if (state.controlLoading) return;
2010
+ state.controlLoading = true;
2011
+ // Don't show skeleton loading flash on poll refreshes — only on first load
2012
+ if (!state.control) showLoading('control');
2013
+ try {
2014
+ state.control = await apiFetch('/api/control');
2015
+ renderControl();
2016
+ } catch (err) {
2017
+ if (!state.control) showError('control', err.message);
2018
+ // On poll failure keep stale data visible — don't replace with error
2019
+ } finally {
2020
+ state.controlLoading = false;
2021
+ }
2022
+
2023
+ // Start polling (8s) if not already running and we're still on this view.
2024
+ if (state.activeView === 'control' && !state.controlInterval) {
2025
+ state.controlInterval = setInterval(() => {
2026
+ if (state.activeView !== 'control') {
2027
+ clearInterval(state.controlInterval);
2028
+ state.controlInterval = null;
2029
+ return;
2030
+ }
2031
+ loadControl();
2032
+ }, 8000);
2033
+ }
2034
+ }
2035
+
2036
+ function renderControl() {
2037
+ if (state.activeView !== 'control') return;
2038
+ const main = getMain();
2039
+ if (!main) return;
2040
+ // Preserve scroll position across the 8s poll re-render (otherwise a watched
2041
+ // dashboard jumps to the top every refresh).
2042
+ const _scrollY = window.scrollY;
2043
+ main.innerHTML = '';
2044
+
2045
+ const d = state.control;
2046
+ const section = el('section', { cls: 'view-section' });
2047
+
2048
+ section.appendChild(el('div', { cls: 'view-header' },
2049
+ el('h1', { cls: 'view-title' }, 'Mission Control'),
2050
+ el('span', { cls: 'view-subtitle' }, d ? `Updated ${fmtRelative(d.ts)}` : 'Live fleet overview')
2051
+ ));
2052
+
2053
+ if (!d) {
2054
+ section.appendChild(el('div', { cls: 'empty-state' },
2055
+ el('p', {}, 'Control data unavailable.'),
2056
+ el('p', { cls: 'hint' }, 'Ensure the daemon is running and the server serves /api/control.')
2057
+ ));
2058
+ main.appendChild(section);
2059
+ return;
2060
+ }
2061
+
2062
+ // ── 1. Fleet Pulse (hero) ──────────────────────────────────────────────
2063
+ const fleet = d.fleet ?? d.daemon ?? {};
2064
+ const daemon = d.daemon ?? fleet.daemon ?? {};
2065
+ const queue = d.fleet?.queue ?? fleet.queue ?? {};
2066
+ const props = d.fleet?.proposals ?? fleet.proposals ?? {};
2067
+ const merges = d.fleet?.merges ?? fleet.merges ?? {};
2068
+ const isRunning = daemon.running ?? false;
2069
+ const isKilled = d.fleet?.killed ?? false;
2070
+
2071
+ if (isKilled) {
2072
+ section.appendChild(el('div', { cls: 'ctrl-banner ctrl-banner--paused' },
2073
+ el('strong', {}, 'Fleet paused'),
2074
+ el('span', {}, ' — kill switch engaged. Resume with `ashlr fleet resume`.')
2075
+ ));
2076
+ }
2077
+
2078
+ const heroPulse = el('div', { cls: 'ctrl-hero' });
2079
+ const daemonStatusEl = el('div', { cls: 'ctrl-daemon-status' },
2080
+ el('span', { cls: `ctrl-live-dot${isRunning ? ' running' : ''}`, title: isRunning ? 'Running' : 'Stopped' }),
2081
+ el('span', { cls: `ctrl-daemon-label${isRunning ? ' running' : ''}` }, isRunning ? 'Daemon running' : 'Daemon stopped'),
2082
+ daemon.pid ? el('span', { cls: 'ctrl-pid' }, `PID ${daemon.pid}`) : null
2083
+ );
2084
+ heroPulse.appendChild(daemonStatusEl);
2085
+
2086
+ if (daemon.lastTickAt) {
2087
+ heroPulse.appendChild(el('div', { cls: 'ctrl-last-tick' }, `Last tick ${fmtRelative(daemon.lastTickAt)}`));
2088
+ }
2089
+
2090
+ const heroMetrics = el('div', { cls: 'ctrl-hero-metrics' });
2091
+ heroMetrics.appendChild(controlMetric('Spend today', daemon.todaySpentUsd != null ? `$${daemon.todaySpentUsd.toFixed(4)}` : '—', '#fbbf24'));
2092
+ heroMetrics.appendChild(controlMetric('Queue depth', queue.backlogItems ?? '—', '#60a5fa'));
2093
+ heroMetrics.appendChild(controlMetric('Proposals', props.pending ?? 0, '#a78bfa'));
2094
+ heroMetrics.appendChild(controlMetric('Merges (24h)', merges.recent ?? '—', '#4ade80'));
2095
+ heroMetrics.appendChild(controlMetric('Kill switch', isKilled ? 'ENGAGED' : 'off', isKilled ? '#f87171' : '#64748b'));
2096
+ heroPulse.appendChild(heroMetrics);
2097
+ section.appendChild(heroPulse);
2098
+
2099
+ // ── 2. Local models ────────────────────────────────────────────────────
2100
+ const modelsData = d.models ?? {};
2101
+ const providers = Array.isArray(modelsData.providers) ? modelsData.providers : [];
2102
+ const activeProvider = modelsData.activeProvider ?? '';
2103
+
2104
+ const modelsCard = el('div', { cls: 'ctrl-card card' });
2105
+ modelsCard.appendChild(el('div', { cls: 'card-header' },
2106
+ el('span', { cls: 'card-title' }, 'Local Models'),
2107
+ el('span', { cls: 'card-subtitle' }, activeProvider ? `active: ${activeProvider}` : 'no active provider')
2108
+ ));
2109
+ const modelsBody = el('div', { cls: 'card-body' });
2110
+
2111
+ if (providers.length === 0) {
2112
+ modelsBody.appendChild(el('p', { cls: 'hint' }, 'No local model providers detected.'));
2113
+ } else {
2114
+ for (const prov of providers) {
2115
+ const isActive = prov.id === activeProvider;
2116
+ const row = el('div', { cls: `ctrl-provider-row${isActive ? ' active' : ''}` });
2117
+ row.appendChild(el('span', { cls: `ctrl-health-dot ${prov.up ? 'up' : 'down'}`, title: prov.up ? 'Up' : 'Down' }));
2118
+ row.appendChild(el('span', { cls: 'ctrl-provider-name' }, prov.id));
2119
+ if (prov.kind) row.appendChild(el('span', { cls: 'ctrl-provider-kind' }, prov.kind));
2120
+ if (prov.baseUrl) row.appendChild(el('span', { cls: 'ctrl-provider-url' }, prov.baseUrl));
2121
+ if (isActive) row.appendChild(el('span', { cls: 'ctrl-active-badge' }, 'active'));
2122
+ const chips = el('div', { cls: 'ctrl-model-chips' });
2123
+ const models = Array.isArray(prov.models) ? prov.models : [];
2124
+ if (models.length === 0) {
2125
+ chips.appendChild(el('span', { cls: 'ctrl-model-chip muted' }, 'no models'));
2126
+ } else {
2127
+ models.forEach((m) => chips.appendChild(el('span', { cls: 'ctrl-model-chip' }, typeof m === 'string' ? m : (m.id ?? m.name ?? String(m)))));
2128
+ }
2129
+ row.appendChild(chips);
2130
+ modelsBody.appendChild(row);
2131
+ }
2132
+ }
2133
+ modelsCard.appendChild(modelsBody);
2134
+ section.appendChild(modelsCard);
2135
+
2136
+ // ── 3. Backends & limits ───────────────────────────────────────────────
2137
+ const backends = Array.isArray(d.fleet?.backends) ? d.fleet.backends : [];
2138
+ const limits = Array.isArray(d.limits) ? d.limits : [];
2139
+ const subLimits = d.subscriptionLimits ?? {};
2140
+
2141
+ const backendsCard = el('div', { cls: 'ctrl-card card' });
2142
+ backendsCard.appendChild(el('div', { cls: 'card-header' },
2143
+ el('span', { cls: 'card-title' }, 'Backends & Limits')
2144
+ ));
2145
+ const backendsBody = el('div', { cls: 'card-body' });
2146
+
2147
+ if (subLimits.note) {
2148
+ backendsBody.appendChild(el('div', { cls: 'ctrl-sub-note' },
2149
+ el('span', { cls: 'ctrl-sub-icon' }, '⚠'),
2150
+ el('span', {}, subLimits.note)
2151
+ ));
2152
+ }
2153
+
2154
+ // ── M63: rolling-window usage (REAL, from local transcripts) ──────────────
2155
+ const subWindows = Array.isArray(subLimits.windows) ? subLimits.windows : [];
2156
+ if (subWindows.length > 0) {
2157
+ backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Rolling usage (transcript-sourced)'));
2158
+ // Group by window label so we render 5h then 24h sections
2159
+ for (const windowLabel of ['5h', '24h']) {
2160
+ const rows = subWindows.filter((w) => w.window === windowLabel);
2161
+ if (rows.length === 0) continue;
2162
+ const section = el('div', { cls: 'ctrl-win-section' });
2163
+ section.appendChild(el('div', { cls: 'ctrl-win-label' }, `Last ${windowLabel}`));
2164
+ for (const w of rows) {
2165
+ const tokStr = typeof w.tokens === 'number' ? fmtK(w.tokens) : '—';
2166
+ const costStr = typeof w.costUsd === 'number' ? `$${fmt(w.costUsd, 4)}` : '—';
2167
+ section.appendChild(el('div', { cls: 'ctrl-win-row' },
2168
+ el('span', { cls: 'ctrl-win-provider' }, w.provider ?? '?'),
2169
+ el('span', { cls: 'ctrl-win-tokens' }, tokStr),
2170
+ el('span', { cls: 'ctrl-win-cost' }, costStr)
2171
+ ));
2172
+ }
2173
+ backendsBody.appendChild(section);
2174
+ }
2175
+ }
2176
+
2177
+ // ── M63/M64: provider subscription/key status ────────────────────────────
2178
+ const subProviders = Array.isArray(subLimits.providers) ? subLimits.providers : [];
2179
+ if (subProviders.length > 0) {
2180
+ backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Provider status'));
2181
+ for (const p of subProviders) {
2182
+ const kindBadge = el('span', {
2183
+ cls: `ctrl-prov-kind-badge ${p.kind === 'api-key' ? 'api-key' : 'subscription'}`
2184
+ }, p.kind === 'api-key' ? 'API key' : 'subscription');
2185
+
2186
+ // M64: for providers with real used% (e.g. Codex), render a usage bar + reset time
2187
+ // p.used is used_percent (0-100) when coming from Codex rate-limits
2188
+ const hasUsedPct = typeof p.used === 'number' && p.used >= 0 && p.used <= 100;
2189
+ const isSubscriptionPct = p.kind === 'subscription' && hasUsedPct;
2190
+
2191
+ const row = el('div', { cls: 'ctrl-prov-status-row' },
2192
+ el('span', { cls: 'ctrl-prov-status-name' }, p.provider ?? '?'),
2193
+ kindBadge,
2194
+ el('span', { cls: 'ctrl-prov-status-detail' }, p.detail ?? ''),
2195
+ // API-key providers show raw token counts; subscription providers with used%
2196
+ // get a percentage badge (not "tok")
2197
+ !isSubscriptionPct && typeof p.used === 'number'
2198
+ ? el('span', { cls: 'ctrl-prov-status-used' }, fmtK(p.used) + ' tok')
2199
+ : null
2200
+ );
2201
+
2202
+ // Rate-limit bar for subscription providers with real used% data (Codex)
2203
+ if (isSubscriptionPct) {
2204
+ const pct = Math.min(100, Math.round(p.used));
2205
+ const barColor = pct >= 90 ? 'var(--status-failed)'
2206
+ : pct >= 70 ? 'var(--status-aborted)'
2207
+ : 'var(--accent)';
2208
+ const resetStr = p.resetAt
2209
+ ? new Date(p.resetAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
2210
+ : null;
2211
+ const barWrap = el('div', { cls: 'ctrl-prov-rl-wrap' },
2212
+ el('div', { cls: 'ctrl-prov-rl-bar-track' },
2213
+ el('div', { cls: 'ctrl-prov-rl-bar', style: `width:${pct}%;background:${barColor}` })
2214
+ ),
2215
+ el('span', { cls: 'ctrl-prov-rl-pct' }, `${pct}%`),
2216
+ resetStr ? el('span', { cls: 'ctrl-prov-rl-reset' }, `resets ${resetStr}`) : null
2217
+ );
2218
+ row.appendChild(barWrap);
2219
+ }
2220
+
2221
+ backendsBody.appendChild(row);
2222
+ }
2223
+ }
2224
+
2225
+ if (backends.length === 0 && limits.length === 0) {
2226
+ backendsBody.appendChild(el('p', { cls: 'hint' }, 'No backends configured.'));
2227
+ }
2228
+
2229
+ if (backends.length > 0) {
2230
+ const bList = el('div', { cls: 'ctrl-backend-list' });
2231
+ for (const b of backends) {
2232
+ bList.appendChild(el('div', { cls: 'ctrl-backend-row' },
2233
+ el('span', { cls: 'ctrl-backend-name' }, b.id ?? b.backend ?? '?'),
2234
+ el('span', { cls: 'ctrl-backend-dispatches' }, `${b.dispatchesRecent ?? 0} dispatch(es) / 24h`),
2235
+ quotaTag(b.quota ?? 'ok')
2236
+ ));
2237
+ }
2238
+ backendsBody.appendChild(bList);
2239
+ }
2240
+
2241
+ if (limits.length > 0) {
2242
+ backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Rate windows'));
2243
+ for (const lim of limits) {
2244
+ const used = lim.used ?? 0;
2245
+ const max = lim.max ?? 1;
2246
+ const pct = Math.min(100, max > 0 ? Math.round((used / max) * 100) : 0);
2247
+ const standing = lim.standing ?? 'ok';
2248
+ const barColor = standing === 'over' ? 'var(--status-failed)'
2249
+ : standing === 'warn' ? 'var(--status-aborted)'
2250
+ : 'var(--accent)';
2251
+ backendsBody.appendChild(el('div', { cls: 'ctrl-limit-row' },
2252
+ el('span', { cls: 'ctrl-limit-backend' }, lim.backend ?? '?'),
2253
+ el('span', { cls: 'ctrl-limit-window' }, lim.window ?? ''),
2254
+ el('div', { cls: 'ctrl-limit-bar-wrap' },
2255
+ el('div', { cls: 'ctrl-limit-bar', style: `width:${pct}%;background:${barColor}` })
2256
+ ),
2257
+ el('span', { cls: 'ctrl-limit-label' }, `${used}/${max === Infinity ? '∞' : max}`),
2258
+ el('span', { cls: `ctrl-standing ${standing}` }, standing)
2259
+ ));
2260
+ }
2261
+ }
2262
+
2263
+ backendsCard.appendChild(backendsBody);
2264
+ section.appendChild(backendsCard);
2265
+
2266
+ // ── 4. Usage (7d) ─────────────────────────────────────────────────────
2267
+ const usage = d.usage ?? {};
2268
+ const byProvider = Array.isArray(usage.byProvider) ? usage.byProvider : [];
2269
+
2270
+ const usageCard = el('div', { cls: 'ctrl-card card' });
2271
+ usageCard.appendChild(el('div', { cls: 'card-header' },
2272
+ el('span', { cls: 'card-title' }, `Usage${usage.window ? ` (${usage.window})` : ''}`)
2273
+ ));
2274
+ const usageBody = el('div', { cls: 'card-body' });
2275
+
2276
+ const usageTotals = el('div', { cls: 'ctrl-usage-totals' });
2277
+ usageTotals.appendChild(controlMetric('Total tokens', usage.totalTokens != null ? fmtK(usage.totalTokens) : '—', '#60a5fa'));
2278
+ usageTotals.appendChild(controlMetric('Total cost', usage.totalCostUsd != null ? `$${fmt(usage.totalCostUsd)}` : '—', '#fbbf24'));
2279
+ usageTotals.appendChild(controlMetric('Local savings', usage.localSavingsUsd != null ? `$${fmt(usage.localSavingsUsd)}` : '—', '#4ade80'));
2280
+ usageBody.appendChild(usageTotals);
2281
+
2282
+ if (byProvider.length > 0) {
2283
+ usageBody.appendChild(el('div', { cls: 'ctrl-prov-heading' }, 'By provider'));
2284
+ for (const p of byProvider) {
2285
+ const pct = Math.min(100, Math.round(p.sharePct ?? 0));
2286
+ const isLocal = (p.tier === 'local');
2287
+ usageBody.appendChild(el('div', { cls: 'ctrl-prov-row' },
2288
+ el('span', { cls: `ctrl-prov-label${isLocal ? ' local' : ''}` },
2289
+ `${p.provider ?? '?'}${p.tier ? ` (${p.tier})` : ''}`),
2290
+ el('div', { cls: 'ctrl-prov-bar-wrap' },
2291
+ el('div', { cls: `ctrl-prov-bar${isLocal ? ' local' : ''}`, style: `width:${pct}%` })
2292
+ ),
2293
+ el('span', { cls: 'ctrl-prov-pct' }, `${pct}%`),
2294
+ el('span', { cls: 'ctrl-prov-cost' }, p.costUsd != null ? `$${fmt(p.costUsd)}` : ''),
2295
+ el('span', { cls: 'ctrl-prov-tokens' }, p.tokens != null ? fmtK(p.tokens) : '')
2296
+ ));
2297
+ }
2298
+ }
2299
+ usageCard.appendChild(usageBody);
2300
+ section.appendChild(usageCard);
2301
+
2302
+ // ── 5. Security (M67) ─────────────────────────────────────────────────
2303
+ const sec = d.security ?? {};
2304
+ const secFindings = Array.isArray(sec.findings) ? sec.findings : [];
2305
+ const secCounts = sec.counts ?? {};
2306
+
2307
+ const secCard = el('div', { cls: 'ctrl-card card' });
2308
+ secCard.appendChild(el('div', { cls: 'card-header' },
2309
+ el('span', { cls: 'card-title' }, 'Security'),
2310
+ el('span', { cls: 'card-subtitle' }, sec.available ? `${secFindings.length} finding(s)` : 'no data')
2311
+ ));
2312
+ const secBody = el('div', { cls: 'card-body' });
2313
+
2314
+ if (!sec.available || secFindings.length === 0) {
2315
+ secBody.appendChild(el('p', { cls: 'hint' }, 'No security findings in cached backlog. Run a backlog scan to populate.'));
2316
+ } else {
2317
+ // Severity count badges
2318
+ const countsRow = el('div', { cls: 'ctrl-sec-counts' });
2319
+ if (secCounts.critical > 0) {
2320
+ countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--critical' }, `${secCounts.critical} critical`));
2321
+ }
2322
+ if (secCounts.high > 0) {
2323
+ countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--high' }, `${secCounts.high} high`));
2324
+ }
2325
+ if (secCounts.medium > 0) {
2326
+ countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--medium' }, `${secCounts.medium} medium`));
2327
+ }
2328
+ if (secCounts.low > 0) {
2329
+ countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--low' }, `${secCounts.low} low`));
2330
+ }
2331
+ secBody.appendChild(countsRow);
2332
+
2333
+ // Findings list (capped at 20 to keep the panel scannable)
2334
+ const list = el('div', { cls: 'ctrl-sec-list' });
2335
+ for (const f of secFindings.slice(0, 20)) {
2336
+ const sev = f.severity ?? 'low';
2337
+ list.appendChild(el('div', { cls: 'ctrl-sec-row' },
2338
+ el('span', { cls: `ctrl-sec-sev ctrl-sec-sev--${sev}` }, sev),
2339
+ el('span', { cls: 'ctrl-sec-repo' }, f.repo ?? '?'),
2340
+ el('span', { cls: 'ctrl-sec-title' }, f.title ?? '')
2341
+ ));
2342
+ }
2343
+ if (secFindings.length > 20) {
2344
+ list.appendChild(el('p', { cls: 'hint' }, `+${secFindings.length - 20} more — run \`ashlr backlog\` to view all.`));
2345
+ }
2346
+ secBody.appendChild(list);
2347
+ }
2348
+ secCard.appendChild(secBody);
2349
+ section.appendChild(secCard);
2350
+
2351
+ // ── 6. Activity log ───────────────────────────────────────────────────
2352
+ const logs = Array.isArray(d.logs) ? [...d.logs].reverse() : [];
2353
+
2354
+ const logsCard = el('div', { cls: 'ctrl-card card' });
2355
+ logsCard.appendChild(el('div', { cls: 'card-header' },
2356
+ el('span', { cls: 'card-title' }, 'Activity Log'),
2357
+ el('span', { cls: 'card-subtitle' }, `${logs.length} recent entries`)
2358
+ ));
2359
+ const logsBody = el('div', { cls: 'ctrl-log-body' });
2360
+
2361
+ if (logs.length === 0) {
2362
+ logsBody.appendChild(el('p', { cls: 'hint' }, 'No recent activity.'));
2363
+ } else {
2364
+ for (const entry of logs.slice(0, 50)) {
2365
+ const kind = entry.kind ?? 'info';
2366
+ logsBody.appendChild(el('div', { cls: `ctrl-log-row ctrl-log-row--${kind}` },
2367
+ el('span', { cls: 'ctrl-log-ts' }, entry.ts ? fmtRelative(entry.ts) : '—'),
2368
+ el('span', { cls: `ctrl-log-kind ctrl-log-kind--${kind}` }, kind),
2369
+ el('span', { cls: 'ctrl-log-msg' }, entry.msg ?? '')
2370
+ ));
2371
+ }
2372
+ }
2373
+ logsCard.appendChild(logsBody);
2374
+ section.appendChild(logsCard);
2375
+
2376
+ main.appendChild(section);
2377
+ if (_scrollY > 0) window.scrollTo(0, _scrollY);
2378
+ }
2379
+
2380
+ // Small metric block used in the Mission Control hero + usage panel
2381
+ function controlMetric(label, value, accent) {
2382
+ return el('div', { cls: 'ctrl-metric' },
2383
+ el('div', { cls: 'ctrl-metric-value', style: `color:${accent}` }, String(value)),
2384
+ el('div', { cls: 'ctrl-metric-label' }, label)
2385
+ );
2386
+ }
2387
+
1882
2388
  // ---------------------------------------------------------------------------
1883
2389
  // Boot
1884
2390
  // ---------------------------------------------------------------------------