lazyclaw 5.0.8 → 5.1.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.
@@ -2,10 +2,12 @@
2
2
  <!--
3
3
  lazyclaw dashboard — a single-file SPA served by `lazyclaw daemon`.
4
4
  Deliberately framework-free: vanilla DOM, vanilla fetch, no build
5
- step. The full lazyclaude dashboard is gigantic; this is the
6
- lazyclaw-only slice (chat / sessions / skills / providers /
7
- status), aimed at users who installed via `npm i -g lazyclaw` and
8
- don't want to clone the whole monorepo just to see a UI.
5
+ step. v5 tab set (17 tabs):
6
+ Chat · Sessions · Workflows · Skills · Providers · Rates · Metrics
7
+ · Doctor · Config · Status · Agents · Teams · Tasks
8
+ · Trainer · Recall · Sandbox · Channels
9
+ Personality / Trajectories / Nodes / SSE bus are deferred to v5.1
10
+ and remain CLI-only for now.
9
11
  -->
10
12
  <html lang="en">
11
13
  <head>
@@ -327,6 +329,10 @@
327
329
  <button data-tab="agents">Agents</button>
328
330
  <button data-tab="teams">Teams</button>
329
331
  <button data-tab="tasks">Tasks</button>
332
+ <button data-tab="trainer">Trainer</button>
333
+ <button data-tab="recall">Recall</button>
334
+ <button data-tab="sandbox">Sandbox</button>
335
+ <button data-tab="channels">Channels</button>
330
336
  </nav>
331
337
 
332
338
  <main>
@@ -351,6 +357,11 @@
351
357
 
352
358
  <section id="tab-skills">
353
359
  <h2>Skills</h2>
360
+ <div class="toolbar">
361
+ <button class="btn btn-secondary" onclick="LOADERS.skills()">Refresh</button>
362
+ <span class="dim" id="skills-meta"></span>
363
+ </div>
364
+ <div id="skills-suggestions"></div>
354
365
  <div id="skills-list"><div class="empty">Loading…</div></div>
355
366
  </section>
356
367
 
@@ -461,6 +472,50 @@
461
472
  </div>
462
473
  <div id="tasks-list"><div class="empty">Loading…</div></div>
463
474
  </section>
475
+
476
+ <section id="tab-trainer">
477
+ <h2>Trainer</h2>
478
+ <div class="toolbar">
479
+ <button class="btn btn-secondary" onclick="LOADERS.trainer()">Refresh</button>
480
+ <span class="dim" id="trainer-meta"></span>
481
+ </div>
482
+ <div id="trainer-card"><div class="empty">Loading…</div></div>
483
+ </section>
484
+
485
+ <section id="tab-recall">
486
+ <h2>Recall</h2>
487
+ <div class="toolbar">
488
+ <input type="search" id="recall-q" placeholder="search sessions / skills / trajectories / memories" style="flex:1;min-width:240px;">
489
+ <select id="recall-scope">
490
+ <option value="all">all scopes</option>
491
+ <option value="sessions">sessions</option>
492
+ <option value="skills">skills</option>
493
+ <option value="trajectories">trajectories</option>
494
+ <option value="memories">memories</option>
495
+ </select>
496
+ <button class="btn" onclick="LOADERS.recall()">Search</button>
497
+ <span class="dim" id="recall-meta"></span>
498
+ </div>
499
+ <div id="recall-list"><div class="empty">Enter a query above.</div></div>
500
+ </section>
501
+
502
+ <section id="tab-sandbox">
503
+ <h2>Sandbox</h2>
504
+ <div class="toolbar">
505
+ <button class="btn btn-secondary" onclick="LOADERS.sandbox()">Refresh</button>
506
+ <span class="dim" id="sandbox-meta"></span>
507
+ </div>
508
+ <div id="sandbox-list"><div class="empty">Loading…</div></div>
509
+ </section>
510
+
511
+ <section id="tab-channels">
512
+ <h2>Channels</h2>
513
+ <div class="toolbar">
514
+ <button class="btn btn-secondary" onclick="LOADERS.channels()">Refresh</button>
515
+ <span class="dim" id="channels-meta"></span>
516
+ </div>
517
+ <div id="channels-list"><div class="empty">Loading…</div></div>
518
+ </section>
464
519
  </main>
465
520
 
466
521
  <footer>
@@ -618,7 +673,7 @@
618
673
  const root = document.getElementById('sessions-list');
619
674
  root.innerHTML = '<div class="empty">Loading…</div>';
620
675
  try {
621
- const r = await api('/sessions');
676
+ const r = await api('/sessions?withV5=true');
622
677
  const arr = r.sessions || r;
623
678
  if (!Array.isArray(arr) || arr.length === 0) {
624
679
  root.innerHTML = '<div class="empty">No persisted sessions yet. Start one with <code>lazyclaw chat --session &lt;id&gt;</code>.</div>';
@@ -631,16 +686,34 @@
631
686
  const id = s.id || s.sessionId || s.name || JSON.stringify(s);
632
687
  const turns = s.turns ?? s.turnCount ?? '';
633
688
  const updated = s.updatedAt || s.mtime || '';
689
+ // v5 columns: trainerHandled / agentName / trajectoryId.
690
+ const tagTrained = s.trainerHandled
691
+ ? `<span class="pill ok" title="trained by ${escHtml(s.trainedBy || 'trainer')}">trained: ${escHtml(s.trainedBy || 'on')}</span>`
692
+ : '';
693
+ const tagAgent = s.agentName
694
+ ? `<span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">@${escHtml(s.agentName)}</span>`
695
+ : '';
696
+ const trajBtn = s.trajectoryId
697
+ ? `<button class="btn btn-secondary btn-sm" data-action="trajectory" data-traj="${escHtml(s.trajectoryId)}">Trajectory</button>`
698
+ : '';
634
699
  div.innerHTML = `<div class="name">${escHtml(id)}</div>
635
700
  <div class="dim">${turns ? turns + ' turns' : ''}</div>
701
+ ${tagTrained}${tagAgent}
636
702
  <div class="dim row-actions">${escHtml(updated)}</div>
637
703
  <button class="btn btn-secondary btn-sm" data-action="view">View</button>
638
704
  <button class="btn btn-secondary btn-sm" data-action="export">Export</button>
705
+ ${trajBtn}
639
706
  <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
640
707
  div.addEventListener('click', (e) => {
641
- const action = e.target.closest('button')?.dataset.action;
708
+ const btn = e.target.closest('button');
709
+ const action = btn?.dataset.action;
642
710
  if (action === 'export') return openSessionExport(id);
643
711
  if (action === 'delete') return deleteSession(id);
712
+ if (action === 'trajectory') {
713
+ const traj = btn.dataset.traj;
714
+ window.open('/trajectories/' + encodeURIComponent(traj), '_blank');
715
+ return;
716
+ }
644
717
  return openSessionDetail(id);
645
718
  });
646
719
  root.appendChild(div);
@@ -697,39 +770,124 @@
697
770
  }
698
771
  }
699
772
 
773
+ // v5 confidence pill color: red <0.4, amber <0.7, green >=0.7.
774
+ function confidencePill(c) {
775
+ if (c == null || c === '') return '';
776
+ const n = Number(c);
777
+ if (!Number.isFinite(n)) return '';
778
+ const cls = n >= 0.7 ? 'ok' : (n >= 0.4 ? 'warn' : 'err');
779
+ return `<span class="pill ${cls}" title="confidence">${n.toFixed(2)}</span>`;
780
+ }
781
+
700
782
  LOADERS.skills = async function loadSkills() {
701
783
  const root = document.getElementById('skills-list');
784
+ const meta = document.getElementById('skills-meta');
785
+ const suggBox = document.getElementById('skills-suggestions');
702
786
  root.innerHTML = '<div class="empty">Loading…</div>';
787
+ // Fire suggestions fetch in parallel; render when ready.
788
+ apiSoft('/skills/suggestions').then(({ body }) => {
789
+ const items = (body && body.suggestions) || [];
790
+ if (!items.length) { suggBox.innerHTML = ''; return; }
791
+ suggBox.innerHTML = `<div class="card" style="border-color:var(--accent);">
792
+ <div class="name" style="color:var(--accent);">Curator suggestions (${items.length})</div>
793
+ ${items.slice(0, 5).map((s) => `<div class="dim" style="margin-top:6px;font-size:12px;">
794
+ ${escHtml(s.suggestion || s.cluster?.sample || '')}
795
+ <span class="dim" style="margin-left:6px;">${s.ts ? new Date(s.ts).toLocaleString() : ''}</span>
796
+ </div>`).join('')}
797
+ </div>`;
798
+ }).catch(() => { suggBox.innerHTML = ''; });
799
+
703
800
  try {
704
801
  const r = await api('/skills');
705
802
  const arr = r.skills || r;
706
803
  if (!Array.isArray(arr) || arr.length === 0) {
707
804
  root.innerHTML = '<div class="empty">No skills yet. Install one: <code>lazyclaw skills install &lt;user&gt;/&lt;repo&gt;</code>.</div>';
805
+ if (meta) meta.textContent = '';
708
806
  return;
709
807
  }
808
+ if (meta) meta.textContent = `${arr.length} skill${arr.length === 1 ? '' : 's'}`;
809
+ // Group by `group` field; fall back to 'ungrouped'.
810
+ const groups = new Map();
811
+ for (const s of arr) {
812
+ const g = (s.group && String(s.group)) || 'ungrouped';
813
+ if (!groups.has(g)) groups.set(g, []);
814
+ groups.get(g).push(s);
815
+ }
816
+ const groupNames = [...groups.keys()].sort();
710
817
  root.innerHTML = '';
711
- arr.forEach((s) => {
712
- const div = document.createElement('div');
713
- div.className = 'card clickable';
714
- div.innerHTML = `<div class="row" style="border:0;padding:0;">
715
- <div class="name">${escHtml(s.name)}</div>
716
- <div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
717
- <button class="btn btn-secondary btn-sm" data-action="view">View</button>
718
- <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
719
- </div>
720
- <div class="dim" style="margin-top:6px;">${escHtml(s.summary || '')}</div>`;
721
- div.addEventListener('click', (e) => {
722
- const action = e.target.closest('button')?.dataset.action;
723
- if (action === 'delete') return deleteSkill(s.name);
724
- return openSkillDetail(s.name);
725
- });
726
- root.appendChild(div);
727
- });
818
+ for (const g of groupNames) {
819
+ const wrap = document.createElement('div');
820
+ wrap.style.marginTop = '12px';
821
+ wrap.innerHTML = `<h3 style="font-size:13px;color:var(--dim);margin:0 0 6px 0;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(g)} · ${groups.get(g).length}</h3>`;
822
+ for (const s of groups.get(g)) {
823
+ const div = document.createElement('div');
824
+ div.className = 'card clickable';
825
+ const trainedTag = s.trained_by
826
+ ? `<span class="pill" title="trained_by">${escHtml(s.trained_by)}</span>`
827
+ : '';
828
+ const conf = confidencePill(s.confidence);
829
+ const crossOk = Array.isArray(s.cross_cli_tested) && s.cross_cli_tested.length
830
+ ? `<span class="pill ok" title="cross-CLI: ${escHtml(s.cross_cli_tested.map((x) => x.provider || '').join(', '))}">x-cli ✓</span>`
831
+ : (s.cross_cli_tested === true
832
+ ? '<span class="pill ok" title="cross-CLI tested">x-cli ✓</span>'
833
+ : '<span class="pill" title="not cross-CLI tested" style="opacity:0.4;">x-cli —</span>');
834
+ div.innerHTML = `<div class="row" style="border:0;padding:0;">
835
+ <div class="name">${escHtml(s.name)}</div>
836
+ ${trainedTag}${conf}${crossOk}
837
+ <div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
838
+ <button class="btn btn-secondary btn-sm" data-action="view">View</button>
839
+ <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
840
+ </div>
841
+ <div class="dim" style="margin-top:6px;">${escHtml(s.summary || s.description || '')}</div>`;
842
+ div.addEventListener('click', (e) => {
843
+ const action = e.target.closest('button')?.dataset.action;
844
+ if (action === 'delete') return deleteSkill(s.name);
845
+ return openSkillDetail(s.name);
846
+ });
847
+ wrap.appendChild(div);
848
+ }
849
+ root.appendChild(wrap);
850
+ }
851
+ // Synth-from-task footer.
852
+ const synthBar = document.createElement('div');
853
+ synthBar.className = 'toolbar';
854
+ synthBar.style.marginTop = '14px';
855
+ synthBar.innerHTML = `<input type="text" id="skill-synth-sid" placeholder="sessionId to synthesize" style="flex:1;min-width:200px;">
856
+ <button class="btn" onclick="skillSynth()">Synthesize from task</button>
857
+ <span class="dim" id="skill-synth-result"></span>`;
858
+ root.appendChild(synthBar);
728
859
  } catch (e) {
729
860
  root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
730
861
  }
731
862
  };
732
863
 
864
+ async function skillSynth() {
865
+ const sid = (document.getElementById('skill-synth-sid').value || '').trim();
866
+ const out = document.getElementById('skill-synth-result');
867
+ if (!sid) { out.style.color = 'var(--warn)'; out.textContent = 'enter a sessionId'; return; }
868
+ out.style.color = 'var(--dim)';
869
+ out.textContent = '⏳ synthesizing…';
870
+ try {
871
+ const r = await fetch('/skills/synth', {
872
+ method: 'POST',
873
+ headers: { 'content-type': 'application/json' },
874
+ body: JSON.stringify({ sessionId: sid }),
875
+ });
876
+ const body = await r.json().catch(() => ({}));
877
+ if (r.ok) {
878
+ out.style.color = 'var(--ok)';
879
+ out.textContent = '✓ ' + (body.name ? `created skill "${body.name}"` : (body.message || 'done'));
880
+ LOADERS.skills();
881
+ } else {
882
+ out.style.color = 'var(--err)';
883
+ out.textContent = '✗ ' + (body.error || r.statusText);
884
+ }
885
+ } catch (e) {
886
+ out.style.color = 'var(--err)';
887
+ out.textContent = '✗ ' + e.message;
888
+ }
889
+ }
890
+
733
891
  async function openSkillDetail(name) {
734
892
  openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
735
893
  try {
@@ -902,7 +1060,17 @@
902
1060
  root.innerHTML = '<div class="empty">Loading…</div>';
903
1061
  try {
904
1062
  const r = await api('/status');
905
- root.innerHTML = `<div class="card"><pre>${JSON.stringify(r, null, 2)}</pre></div>`;
1063
+ // v5 one-line banner under the JSON card.
1064
+ const v5 = r.v5 || {};
1065
+ const trainer = v5.trainer || {};
1066
+ const banner = `<div class="banner ok" style="margin-bottom:10px;">
1067
+ <strong>v5:</strong>
1068
+ trainer: <code>${escHtml(trainer.provider || 'off')}/${escHtml(trainer.model || '-')}</code>
1069
+ · index: <code>${escHtml(String(v5.indexRows ?? '?'))} rows</code>
1070
+ · sandbox: <code>${escHtml(v5.sandboxBackend || 'local')}</code>
1071
+ ${v5.migrateBackup ? ` · backup: <code>${escHtml(v5.migrateBackup)}</code>` : ''}
1072
+ </div>`;
1073
+ root.innerHTML = banner + `<div class="card"><pre>${JSON.stringify(r, null, 2)}</pre></div>`;
906
1074
  } catch (e) {
907
1075
  root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
908
1076
  }
@@ -1229,6 +1397,24 @@
1229
1397
  : `<div class="banner err"><div><strong>${issues.length} issue${issues.length === 1 ? '' : 's'}:</strong>
1230
1398
  <ul>${issues.map((i) => `<li>${escHtml(i)}</li>`).join('')}</ul>
1231
1399
  </div></div>`;
1400
+ // v5 index integrity row.
1401
+ const idx = d.index || null;
1402
+ let idxRow = '';
1403
+ if (idx) {
1404
+ const status = idx.ok
1405
+ ? '<span class="pill ok">ok</span>'
1406
+ : '<span class="pill err">degraded</span>';
1407
+ const rowCounts = idx.rowCounts
1408
+ ? Object.entries(idx.rowCounts).map(([k, v]) => `${k}=${v}`).join(' · ')
1409
+ : '';
1410
+ const rebuildBtn = idx.ok
1411
+ ? ''
1412
+ : '<button class="btn btn-danger btn-sm" onclick="rebuildIndex()" style="margin-left:8px;">Rebuild</button>';
1413
+ idxRow = `<div class="row">
1414
+ <div class="name">FTS5 index</div>
1415
+ <div class="dim" style="margin-left:auto;">${status} <span class="dim">${escHtml(rowCounts)}</span>${rebuildBtn}</div>
1416
+ </div>`;
1417
+ }
1232
1418
  root.innerHTML = okBanner + `
1233
1419
  <div class="card">
1234
1420
  <div class="row"><div class="name">Provider</div><div class="dim" style="margin-left:auto;">${escHtml(d.provider || '—')}</div></div>
@@ -1236,10 +1422,24 @@
1236
1422
  <div class="row"><div class="name">API key</div><div class="dim" style="margin-left:auto;">${d.hasApiKey ? '<span class="pill ok">present</span>' : '<span class="pill warn">none</span>'}</div></div>
1237
1423
  <div class="row"><div class="name">Node</div><div class="dim" style="margin-left:auto;">${escHtml(d.nodeVersion || '—')}</div></div>
1238
1424
  <div class="row"><div class="name">Platform</div><div class="dim" style="margin-left:auto;">${escHtml(d.platform || '—')}</div></div>
1425
+ ${idxRow}
1239
1426
  <div class="row"><div class="name">Known providers</div><div class="dim" style="margin-left:auto;">${(d.knownProviders || []).map(escHtml).join(' · ') || '—'}</div></div>
1240
1427
  </div>`;
1241
1428
  };
1242
1429
 
1430
+ async function rebuildIndex() {
1431
+ if (!confirm('Rebuild the FTS5 index? Existing index.db will be deleted and recreated empty.')) return;
1432
+ try {
1433
+ const r = await fetch('/index/rebuild', { method: 'POST' });
1434
+ const body = await r.json().catch(() => ({}));
1435
+ if (!r.ok) { alert('Rebuild failed: ' + (body.error || r.statusText)); return; }
1436
+ alert('Index rebuilt. Re-run Doctor to confirm.');
1437
+ LOADERS.doctor();
1438
+ } catch (e) {
1439
+ alert('Rebuild failed: ' + e.message);
1440
+ }
1441
+ }
1442
+
1243
1443
  // ── Config ───────────────────────────────────────────────────
1244
1444
  LOADERS.config = async function loadConfig() {
1245
1445
  const root = document.getElementById('config-table');
@@ -1517,6 +1717,165 @@
1517
1717
  catch (e) { alert(`${action} failed: ` + e.message); }
1518
1718
  }
1519
1719
 
1720
+ // ── Trainer / Recall / Sandbox / Channels (v5) ───────────────
1721
+ LOADERS.trainer = async function loadTrainer() {
1722
+ const root = document.getElementById('trainer-card');
1723
+ const meta = document.getElementById('trainer-meta');
1724
+ root.innerHTML = '<div class="empty">Loading…</div>';
1725
+ try {
1726
+ const r = await api('/trainer/status');
1727
+ meta.textContent = r.lastRunAt ? `last run ${new Date(r.lastRunAt).toLocaleString()}` : 'no runs recorded';
1728
+ const pct = (r.budget && r.callsToday != null)
1729
+ ? Math.min(100, Math.round((r.callsToday / r.budget) * 100))
1730
+ : null;
1731
+ root.innerHTML = `<div class="card">
1732
+ <div class="row"><div class="name">Provider</div><div class="dim" style="margin-left:auto;">${escHtml(r.provider || '—')}</div></div>
1733
+ <div class="row"><div class="name">Model</div><div class="dim" style="margin-left:auto;">${escHtml(r.model || '—')}</div></div>
1734
+ <div class="row"><div class="name">Schedule</div><div class="dim" style="margin-left:auto;">${escHtml(r.schedule || 'off')}</div></div>
1735
+ <div class="row"><div class="name">Recipe</div><div class="dim" style="margin-left:auto;">${escHtml(r.recipe || 'inherit')}</div></div>
1736
+ <div class="row"><div class="name">Calls today</div><div class="dim" style="margin-left:auto;">${r.callsToday ?? 0}${r.budget ? ` / ${r.budget} (${pct}%)` : ''}</div></div>
1737
+ </div>
1738
+ <div class="toolbar" style="margin-top:10px;">
1739
+ <button class="btn" onclick="trainerSync()">Sync now</button>
1740
+ <span class="dim" id="trainer-sync-result"></span>
1741
+ </div>`;
1742
+ } catch (e) {
1743
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1744
+ }
1745
+ };
1746
+ async function trainerSync() {
1747
+ const out = document.getElementById('trainer-sync-result');
1748
+ out.textContent = '⏳ syncing…';
1749
+ try {
1750
+ const r = await fetch('/trainer/sync', { method: 'POST' });
1751
+ const body = await r.json().catch(() => ({}));
1752
+ out.style.color = r.ok ? 'var(--ok)' : 'var(--err)';
1753
+ out.textContent = r.ok ? '✓ ' + (body.message || 'queued') : '✗ ' + (body.error || r.statusText);
1754
+ } catch (e) {
1755
+ out.style.color = 'var(--err)';
1756
+ out.textContent = '✗ ' + e.message;
1757
+ }
1758
+ }
1759
+
1760
+ LOADERS.recall = async function loadRecall() {
1761
+ const root = document.getElementById('recall-list');
1762
+ const meta = document.getElementById('recall-meta');
1763
+ const q = (document.getElementById('recall-q').value || '').trim();
1764
+ const scope = document.getElementById('recall-scope').value || 'all';
1765
+ if (!q) { root.innerHTML = '<div class="empty">Enter a query above.</div>'; meta.textContent = ''; return; }
1766
+ root.innerHTML = '<div class="empty">Searching…</div>';
1767
+ try {
1768
+ const qs = new URLSearchParams({ q });
1769
+ if (scope && scope !== 'all') qs.set('scope', scope);
1770
+ const r = await api('/recall?' + qs.toString());
1771
+ const hits = r.hits || [];
1772
+ meta.textContent = `${hits.length} hit${hits.length === 1 ? '' : 's'} · ${r.latencyMs?.toFixed(1) ?? '?'} ms`;
1773
+ if (!hits.length) { root.innerHTML = '<div class="empty">No matches.</div>'; return; }
1774
+ root.innerHTML = hits.map((h) => {
1775
+ const md = h.metadata || {};
1776
+ const label = md.session_id || md.skill_name || md.trajectory_id || md.topic || '—';
1777
+ return `<div class="card">
1778
+ <div class="row" style="border:0;padding:0;">
1779
+ <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">${escHtml(h.scope)}</span>
1780
+ <div class="name" style="margin-left:8px;">${escHtml(String(label))}</div>
1781
+ <div class="dim row-actions">bm25 ${Number(h.bm25 || 0).toFixed(2)}</div>
1782
+ </div>
1783
+ <div class="dim" style="margin-top:6px;font-size:12px;">${h.snippet || ''}</div>
1784
+ </div>`;
1785
+ }).join('');
1786
+ } catch (e) {
1787
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1788
+ }
1789
+ };
1790
+ // Wire Enter-to-search.
1791
+ document.getElementById('recall-q').addEventListener('keydown', (e) => {
1792
+ if (e.key === 'Enter') { e.preventDefault(); LOADERS.recall(); }
1793
+ });
1794
+
1795
+ LOADERS.sandbox = async function loadSandbox() {
1796
+ const root = document.getElementById('sandbox-list');
1797
+ const meta = document.getElementById('sandbox-meta');
1798
+ root.innerHTML = '<div class="empty">Loading…</div>';
1799
+ try {
1800
+ const r = await api('/sandbox');
1801
+ const profiles = r.profiles || [];
1802
+ meta.textContent = `active: ${r.active || 'local'}`;
1803
+ root.innerHTML = profiles.map((p) => {
1804
+ const isActive = p.name === r.active;
1805
+ return `<div class="card">
1806
+ <div class="row" style="border:0;padding:0;">
1807
+ <div class="name">${escHtml(p.name)}</div>
1808
+ ${isActive ? '<span class="pill ok">active</span>' : ''}
1809
+ ${p.configured ? '' : '<span class="pill warn">unconfigured</span>'}
1810
+ <div class="dim row-actions">${escHtml(p.summary || '')}</div>
1811
+ <button class="btn btn-secondary btn-sm" data-action="test" data-name="${escHtml(p.name)}">Test</button>
1812
+ ${isActive ? '' : `<button class="btn btn-sm" data-action="use" data-name="${escHtml(p.name)}">Use</button>`}
1813
+ </div>
1814
+ <div class="dim" data-test-result="${escHtml(p.name)}" style="margin-top:6px;font-size:11px;"></div>
1815
+ </div>`;
1816
+ }).join('');
1817
+ root.querySelectorAll('button[data-action="test"]').forEach((b) => {
1818
+ b.addEventListener('click', () => sandboxTest(b.dataset.name));
1819
+ });
1820
+ root.querySelectorAll('button[data-action="use"]').forEach((b) => {
1821
+ b.addEventListener('click', () => sandboxUse(b.dataset.name));
1822
+ });
1823
+ } catch (e) {
1824
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1825
+ }
1826
+ };
1827
+ async function sandboxTest(name) {
1828
+ const out = document.querySelector(`[data-test-result="${name}"]`);
1829
+ if (!out) return;
1830
+ out.textContent = '⏳ testing…';
1831
+ out.style.color = 'var(--dim)';
1832
+ try {
1833
+ const r = await fetch('/sandbox/' + encodeURIComponent(name) + '/test', { method: 'POST' });
1834
+ const body = await r.json().catch(() => ({}));
1835
+ if (body.ok) {
1836
+ out.style.color = 'var(--ok)';
1837
+ out.textContent = `✓ ok · ${body.durationMs || '?'}ms · ${String(body.stdout || '').slice(0, 80)}`;
1838
+ } else {
1839
+ out.style.color = 'var(--err)';
1840
+ out.textContent = `✗ ${body.error || 'failed'}`;
1841
+ }
1842
+ } catch (e) {
1843
+ out.style.color = 'var(--err)';
1844
+ out.textContent = '✗ ' + e.message;
1845
+ }
1846
+ }
1847
+ async function sandboxUse(name) {
1848
+ try {
1849
+ const r = await fetch('/sandbox/use', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
1850
+ if (!r.ok) { const e = await r.json().catch(() => ({})); alert('Failed: ' + (e.error || r.statusText)); return; }
1851
+ LOADERS.sandbox();
1852
+ } catch (e) { alert('Failed: ' + e.message); }
1853
+ }
1854
+
1855
+ LOADERS.channels = async function loadChannels() {
1856
+ const root = document.getElementById('channels-list');
1857
+ const meta = document.getElementById('channels-meta');
1858
+ root.innerHTML = '<div class="empty">Loading…</div>';
1859
+ try {
1860
+ const r = await api('/channels');
1861
+ const arr = r.channels || [];
1862
+ meta.textContent = `${arr.length} channel${arr.length === 1 ? '' : 's'}`;
1863
+ if (!arr.length) { root.innerHTML = '<div class="empty">No channels configured. Configure via <code>lazyclaw config set channels.&lt;name&gt; ...</code>.</div>'; return; }
1864
+ root.innerHTML = arr.map((c) => `<div class="card">
1865
+ <div class="row" style="border:0;padding:0;">
1866
+ <div class="name">${escHtml(c.name)}</div>
1867
+ ${c.enabled ? '<span class="pill ok">enabled</span>' : '<span class="pill warn">disabled</span>'}
1868
+ <div class="dim row-actions">${c.boundAgent ? 'agent: ' + escHtml(c.boundAgent) : '<span class="dim">no binding</span>'}</div>
1869
+ </div>
1870
+ <div class="dim" style="margin-top:6px;font-size:12px;">
1871
+ last inbound: ${c.lastInboundAt ? new Date(c.lastInboundAt).toLocaleString() : '—'}
1872
+ </div>
1873
+ </div>`).join('');
1874
+ } catch (e) {
1875
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1876
+ }
1877
+ };
1878
+
1520
1879
  // First load = chat tab.
1521
1880
  LOADERS.chat();
1522
1881